- Add generic move_in_order() to BaseRepository for reorder support - Add reusable reorder_arrows.html partial with grip dot handles - Add reorder routes to all 9 list routers (tasks, notes, links, contacts, meetings, decisions, appointments, lists, focus) - Compact row padding (6px 12px, gap 8px) on .list-row and .focus-item - Reduce font size to 0.80rem on row titles, sidebar nav, domain tree Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
315 lines
10 KiB
Python
315 lines
10 KiB
Python
"""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)
|
|
|
|
|
|
@router.post("/reorder")
|
|
async def reorder_appointment(
|
|
request: Request,
|
|
item_id: str = Form(...),
|
|
direction: str = Form(...),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
repo = BaseRepository("appointments", db)
|
|
await repo.move_in_order(item_id, direction)
|
|
return RedirectResponse(url=request.headers.get("referer", "/appointments"), status_code=303)
|