"""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)