Initial commit

This commit is contained in:
2026-03-03 00:44:33 +00:00
commit 5297da485f
126 changed files with 54767 additions and 0 deletions

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)