Tier 3: appointments CRUD + time tracking with topbar timer

This commit is contained in:
2026-02-28 04:38:56 +00:00
parent 82d03ce23a
commit 6ad642084d
13 changed files with 1075 additions and 0 deletions

View File

@@ -27,6 +27,9 @@ TRASH_ENTITIES = [
{"table": "meetings", "label": "Meetings", "name_col": "title", "url": "/meetings/{id}"},
{"table": "decisions", "label": "Decisions", "name_col": "title", "url": "/decisions/{id}"},
{"table": "files", "label": "Files", "name_col": "original_filename", "url": "/files"},
{"table": "weblinks", "label": "Weblinks", "name_col": "label", "url": "/weblinks"},
{"table": "weblink_folders", "label": "Weblink Folders", "name_col": "name", "url": "/weblinks"},
{"table": "appointments", "label": "Appointments", "name_col": "title", "url": "/appointments/{id}"},
{"table": "capture", "label": "Capture", "name_col": "raw_text", "url": "/capture"},
{"table": "daily_focus", "label": "Focus Items", "name_col": "id", "url": "/focus"},
]

302
routers/appointments.py Normal file
View File

@@ -0,0 +1,302 @@
"""Appointments CRUD: scheduling with contacts, recurrence, all-day support."""
from fastapi import APIRouter, Request, Depends, Form, Query
from fastapi.templating import Jinja2Templates
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from typing import Optional
from datetime import datetime
from core.database import get_db
from core.base_repository import BaseRepository
from core.sidebar import get_sidebar_data
router = APIRouter(prefix="/appointments", tags=["appointments"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def list_appointments(
request: Request,
status: Optional[str] = None,
timeframe: Optional[str] = "upcoming",
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
repo = BaseRepository("appointments", db)
# Build filter and sort based on timeframe
if timeframe == "past":
result = await db.execute(text("""
SELECT * FROM appointments
WHERE is_deleted = false AND start_at < now()
ORDER BY start_at DESC
LIMIT 100
"""))
elif timeframe == "all":
result = await db.execute(text("""
SELECT * FROM appointments
WHERE is_deleted = false
ORDER BY start_at DESC
LIMIT 200
"""))
else:
# upcoming (default)
result = await db.execute(text("""
SELECT * FROM appointments
WHERE is_deleted = false AND start_at >= CURRENT_DATE
ORDER BY start_at ASC
LIMIT 100
"""))
appointments = [dict(r._mapping) for r in result]
# Get contact counts per appointment
if appointments:
ids = [str(a["id"]) for a in appointments]
placeholders = ", ".join(f"'{i}'" for i in ids)
contact_result = await db.execute(text(f"""
SELECT appointment_id, count(*) as cnt
FROM contact_appointments
WHERE appointment_id IN ({placeholders})
GROUP BY appointment_id
"""))
contact_counts = {str(r._mapping["appointment_id"]): r._mapping["cnt"] for r in contact_result}
for a in appointments:
a["contact_count"] = contact_counts.get(str(a["id"]), 0)
count = len(appointments)
return templates.TemplateResponse("appointments.html", {
"request": request,
"sidebar": sidebar,
"appointments": appointments,
"count": count,
"timeframe": timeframe or "upcoming",
"page_title": "Appointments",
"active_nav": "appointments",
})
@router.get("/new")
async def new_appointment(request: Request, db: AsyncSession = Depends(get_db)):
sidebar = await get_sidebar_data(db)
# Load contacts for attendee selection
result = await db.execute(text("""
SELECT id, first_name, last_name, company
FROM contacts WHERE is_deleted = false
ORDER BY first_name, last_name
"""))
contacts = [dict(r._mapping) for r in result]
return templates.TemplateResponse("appointment_form.html", {
"request": request,
"sidebar": sidebar,
"appointment": None,
"contacts": contacts,
"selected_contacts": [],
"page_title": "New Appointment",
"active_nav": "appointments",
})
@router.post("/create")
async def create_appointment(
request: Request,
title: str = Form(...),
description: Optional[str] = Form(None),
location: Optional[str] = Form(None),
start_date: str = Form(...),
start_time: Optional[str] = Form(None),
end_date: Optional[str] = Form(None),
end_time: Optional[str] = Form(None),
all_day: Optional[str] = Form(None),
recurrence: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
contact_ids: Optional[list[str]] = Form(None),
db: AsyncSession = Depends(get_db),
):
is_all_day = all_day == "on"
# Build start_at
if is_all_day:
start_at = f"{start_date}T00:00:00"
else:
start_at = f"{start_date}T{start_time or '09:00'}:00"
# Build end_at
end_at = None
if end_date:
if is_all_day:
end_at = f"{end_date}T23:59:59"
else:
end_at = f"{end_date}T{end_time or '10:00'}:00"
data = {
"title": title,
"description": description or None,
"location": location or None,
"start_at": start_at,
"end_at": end_at,
"all_day": is_all_day,
"recurrence": recurrence or None,
"tags": [t.strip() for t in tags.split(",") if t.strip()] if tags else None,
}
repo = BaseRepository("appointments", db)
appointment = await repo.create(data)
# Add contact associations
if contact_ids:
for cid in contact_ids:
if cid:
await db.execute(text("""
INSERT INTO contact_appointments (contact_id, appointment_id)
VALUES (:cid, :aid)
ON CONFLICT DO NOTHING
"""), {"cid": cid, "aid": str(appointment["id"])})
return RedirectResponse(url=f"/appointments/{appointment['id']}", status_code=303)
@router.get("/{appointment_id}")
async def appointment_detail(
request: Request,
appointment_id: str,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
repo = BaseRepository("appointments", db)
appointment = await repo.get(appointment_id)
if not appointment:
return RedirectResponse(url="/appointments", status_code=303)
# Get associated contacts
result = await db.execute(text("""
SELECT c.id, c.first_name, c.last_name, c.company, c.email, ca.role
FROM contact_appointments ca
JOIN contacts c ON ca.contact_id = c.id
WHERE ca.appointment_id = :aid AND c.is_deleted = false
ORDER BY c.first_name, c.last_name
"""), {"aid": appointment_id})
contacts = [dict(r._mapping) for r in result]
return templates.TemplateResponse("appointment_detail.html", {
"request": request,
"sidebar": sidebar,
"appointment": appointment,
"contacts": contacts,
"page_title": appointment["title"],
"active_nav": "appointments",
})
@router.get("/{appointment_id}/edit")
async def edit_appointment_form(
request: Request,
appointment_id: str,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
repo = BaseRepository("appointments", db)
appointment = await repo.get(appointment_id)
if not appointment:
return RedirectResponse(url="/appointments", status_code=303)
# All contacts
result = await db.execute(text("""
SELECT id, first_name, last_name, company
FROM contacts WHERE is_deleted = false
ORDER BY first_name, last_name
"""))
contacts = [dict(r._mapping) for r in result]
# Currently linked contacts
result = await db.execute(text("""
SELECT contact_id FROM contact_appointments WHERE appointment_id = :aid
"""), {"aid": appointment_id})
selected_contacts = [str(r._mapping["contact_id"]) for r in result]
return templates.TemplateResponse("appointment_form.html", {
"request": request,
"sidebar": sidebar,
"appointment": appointment,
"contacts": contacts,
"selected_contacts": selected_contacts,
"page_title": f"Edit: {appointment['title']}",
"active_nav": "appointments",
})
@router.post("/{appointment_id}/edit")
async def update_appointment(
request: Request,
appointment_id: str,
title: str = Form(...),
description: Optional[str] = Form(None),
location: Optional[str] = Form(None),
start_date: str = Form(...),
start_time: Optional[str] = Form(None),
end_date: Optional[str] = Form(None),
end_time: Optional[str] = Form(None),
all_day: Optional[str] = Form(None),
recurrence: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
contact_ids: Optional[list[str]] = Form(None),
db: AsyncSession = Depends(get_db),
):
is_all_day = all_day == "on"
if is_all_day:
start_at = f"{start_date}T00:00:00"
else:
start_at = f"{start_date}T{start_time or '09:00'}:00"
end_at = None
if end_date:
if is_all_day:
end_at = f"{end_date}T23:59:59"
else:
end_at = f"{end_date}T{end_time or '10:00'}:00"
data = {
"title": title,
"description": description or None,
"location": location or None,
"start_at": start_at,
"end_at": end_at,
"all_day": is_all_day,
"recurrence": recurrence or None,
"tags": [t.strip() for t in tags.split(",") if t.strip()] if tags else None,
}
repo = BaseRepository("appointments", db)
await repo.update(appointment_id, data)
# Rebuild contact associations
await db.execute(text("DELETE FROM contact_appointments WHERE appointment_id = :aid"), {"aid": appointment_id})
if contact_ids:
for cid in contact_ids:
if cid:
await db.execute(text("""
INSERT INTO contact_appointments (contact_id, appointment_id)
VALUES (:cid, :aid)
ON CONFLICT DO NOTHING
"""), {"cid": cid, "aid": appointment_id})
return RedirectResponse(url=f"/appointments/{appointment_id}", status_code=303)
@router.post("/{appointment_id}/delete")
async def delete_appointment(
request: Request,
appointment_id: str,
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("appointments", db)
await repo.soft_delete(appointment_id)
return RedirectResponse(url="/appointments", status_code=303)

View File

@@ -137,6 +137,34 @@ SEARCH_ENTITIES = [
"url": "/decisions/{id}",
"icon": "decision",
},
{
"type": "weblinks",
"label": "Weblinks",
"query": """
SELECT w.id, w.label as name, NULL as status,
NULL as domain_name, NULL as project_name,
ts_rank(w.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM weblinks w
WHERE w.is_deleted = false AND w.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/weblinks",
"icon": "weblink",
},
{
"type": "appointments",
"label": "Appointments",
"query": """
SELECT a.id, a.title as name, NULL as status,
a.location as domain_name, NULL as project_name,
ts_rank(a.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM appointments a
WHERE a.is_deleted = false AND a.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/appointments/{id}",
"icon": "appointment",
},
]

233
routers/weblinks.py Normal file
View File

@@ -0,0 +1,233 @@
"""Weblinks: organized bookmark directory with recursive folders."""
from fastapi import APIRouter, Request, Form, Depends
from fastapi.templating import Jinja2Templates
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from typing import Optional
from core.database import get_db
from core.base_repository import BaseRepository
from core.sidebar import get_sidebar_data
router = APIRouter(prefix="/weblinks", tags=["weblinks"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def list_weblinks(
request: Request,
folder_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
# Get all folders for tree nav
result = await db.execute(text("""
SELECT wf.*, (SELECT count(*) FROM folder_weblinks fw WHERE fw.folder_id = wf.id) as link_count
FROM weblink_folders wf
WHERE wf.is_deleted = false
ORDER BY wf.sort_order, wf.name
"""))
all_folders = [dict(r._mapping) for r in result]
# Top-level folders and child folders
top_folders = [f for f in all_folders if f.get("parent_id") is None]
child_folder_map = {}
for f in all_folders:
pid = f.get("parent_id")
if pid:
child_folder_map.setdefault(str(pid), []).append(f)
# Current folder info
current_folder = None
if folder_id:
for f in all_folders:
if str(f["id"]) == folder_id:
current_folder = f
break
# Get weblinks (filtered by folder or all unfiled)
if folder_id:
result = await db.execute(text("""
SELECT w.* FROM weblinks w
JOIN folder_weblinks fw ON fw.weblink_id = w.id
WHERE fw.folder_id = :fid AND w.is_deleted = false
ORDER BY fw.sort_order, w.label
"""), {"fid": folder_id})
else:
# Show all weblinks
result = await db.execute(text("""
SELECT w.* FROM weblinks w
WHERE w.is_deleted = false
ORDER BY w.sort_order, w.label
"""))
items = [dict(r._mapping) for r in result]
return templates.TemplateResponse("weblinks.html", {
"request": request, "sidebar": sidebar, "items": items,
"top_folders": top_folders, "child_folder_map": child_folder_map,
"current_folder": current_folder,
"current_folder_id": folder_id or "",
"page_title": "Weblinks", "active_nav": "weblinks",
})
@router.get("/create")
async def create_form(
request: Request,
folder_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
result = await db.execute(text(
"SELECT id, name FROM weblink_folders WHERE is_deleted = false ORDER BY name"
))
folders = [dict(r._mapping) for r in result]
return templates.TemplateResponse("weblink_form.html", {
"request": request, "sidebar": sidebar,
"folders": folders,
"page_title": "New Weblink", "active_nav": "weblinks",
"item": None,
"prefill_folder_id": folder_id or "",
})
@router.post("/create")
async def create_weblink(
request: Request,
label: str = Form(...),
url: str = Form(...),
description: Optional[str] = Form(None),
folder_id: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("weblinks", db)
data = {"label": label, "url": url, "description": description}
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
weblink = await repo.create(data)
# Add to folder if specified
if folder_id and folder_id.strip():
await db.execute(text("""
INSERT INTO folder_weblinks (folder_id, weblink_id)
VALUES (:fid, :wid) ON CONFLICT DO NOTHING
"""), {"fid": folder_id, "wid": weblink["id"]})
redirect_url = f"/weblinks?folder_id={folder_id}" if folder_id and folder_id.strip() else "/weblinks"
return RedirectResponse(url=redirect_url, status_code=303)
@router.get("/{weblink_id}/edit")
async def edit_form(weblink_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("weblinks", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(weblink_id)
if not item:
return RedirectResponse(url="/weblinks", status_code=303)
result = await db.execute(text(
"SELECT id, name FROM weblink_folders WHERE is_deleted = false ORDER BY name"
))
folders = [dict(r._mapping) for r in result]
# Current folder assignment
result = await db.execute(text(
"SELECT folder_id FROM folder_weblinks WHERE weblink_id = :wid LIMIT 1"
), {"wid": weblink_id})
row = result.first()
current_folder_id = str(row[0]) if row else ""
return templates.TemplateResponse("weblink_form.html", {
"request": request, "sidebar": sidebar,
"folders": folders,
"page_title": "Edit Weblink", "active_nav": "weblinks",
"item": item,
"prefill_folder_id": current_folder_id,
})
@router.post("/{weblink_id}/edit")
async def update_weblink(
weblink_id: str,
label: str = Form(...),
url: str = Form(...),
description: Optional[str] = Form(None),
folder_id: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("weblinks", db)
data = {
"label": label, "url": url,
"description": description if description and description.strip() else None,
}
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
else:
data["tags"] = None
await repo.update(weblink_id, data)
# Update folder assignment
await db.execute(text("DELETE FROM folder_weblinks WHERE weblink_id = :wid"), {"wid": weblink_id})
if folder_id and folder_id.strip():
await db.execute(text("""
INSERT INTO folder_weblinks (folder_id, weblink_id)
VALUES (:fid, :wid) ON CONFLICT DO NOTHING
"""), {"fid": folder_id, "wid": weblink_id})
return RedirectResponse(url="/weblinks", status_code=303)
@router.post("/{weblink_id}/delete")
async def delete_weblink(weblink_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("weblinks", db)
await repo.soft_delete(weblink_id)
referer = request.headers.get("referer", "/weblinks")
return RedirectResponse(url=referer, status_code=303)
# ---- Folders ----
@router.get("/folders/create")
async def create_folder_form(request: Request, db: AsyncSession = Depends(get_db)):
sidebar = await get_sidebar_data(db)
result = await db.execute(text(
"SELECT id, name FROM weblink_folders WHERE is_deleted = false ORDER BY name"
))
parent_folders = [dict(r._mapping) for r in result]
return templates.TemplateResponse("weblink_folder_form.html", {
"request": request, "sidebar": sidebar,
"parent_folders": parent_folders,
"page_title": "New Folder", "active_nav": "weblinks",
"item": None,
})
@router.post("/folders/create")
async def create_folder(
request: Request,
name: str = Form(...),
parent_id: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("weblink_folders", db)
data = {"name": name}
if parent_id and parent_id.strip():
data["parent_id"] = parent_id
await repo.create(data)
return RedirectResponse(url="/weblinks", status_code=303)
@router.post("/folders/{folder_id}/delete")
async def delete_folder(folder_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("weblink_folders", db)
await repo.soft_delete(folder_id)
return RedirectResponse(url="/weblinks", status_code=303)