Tier 3: appointments CRUD + time tracking with topbar timer
This commit is contained in:
302
routers/appointments.py
Normal file
302
routers/appointments.py
Normal 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)
|
||||
Reference in New Issue
Block a user