Files
common-availability/backend/src/app/main.py
2026-02-02 13:49:11 -05:00

221 lines
6.9 KiB
Python

import asyncio
import logging
from uuid import UUID
from fastapi import Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.availability_service import calculate_availability
from app.database import get_db
from app.ics_service import sync_all_calendars, sync_participant_calendar
from app.models import Participant
from app.schemas import (
AvailabilityRequest,
AvailabilityResponse,
ParticipantCreate,
ParticipantUpdate,
ParticipantResponse,
SyncResponse,
ScheduleRequest,
)
from app.scheduler import start_scheduler, stop_scheduler
from app.email_service import send_meeting_invite
from app.zulip_service import send_zulip_notification
from contextlib import asynccontextmanager
from datetime import datetime, timezone, timedelta
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
start_scheduler()
yield
stop_scheduler()
app = FastAPI(title="Common Availability API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
async def health_check():
return {"status": "healthy"}
@app.post("/api/participants", response_model=ParticipantResponse)
async def create_participant(
data: ParticipantCreate, db: AsyncSession = Depends(get_db)
):
existing = await db.execute(
select(Participant).where(Participant.email == data.email)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already registered")
participant = Participant(
name=data.name,
email=data.email,
timezone=data.timezone,
ics_url=data.ics_url,
)
db.add(participant)
await db.commit()
await db.refresh(participant)
try:
if participant.ics_url:
await sync_participant_calendar(db, participant)
except Exception as e:
logger.warning(f"Initial sync failed for {participant.email}: {e}")
return participant
@app.get("/api/participants", response_model=list[ParticipantResponse])
async def list_participants(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Participant))
return result.scalars().all()
@app.get("/api/participants/{participant_id}", response_model=ParticipantResponse)
async def get_participant(participant_id: UUID, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Participant).where(Participant.id == participant_id)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=404, detail="Participant not found")
return participant
@app.patch("/api/participants/{participant_id}", response_model=ParticipantResponse)
async def update_participant(
participant_id: UUID, data: ParticipantUpdate, db: AsyncSession = Depends(get_db)
):
result = await db.execute(
select(Participant).where(Participant.id == participant_id)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=404, detail="Participant not found")
if data.timezone is not None:
participant.timezone = data.timezone
if data.ics_url is not None:
participant.ics_url = data.ics_url if data.ics_url else None
await db.commit()
await db.refresh(participant)
# Re-sync calendar if ICS URL was updated
if data.ics_url is not None and participant.ics_url:
try:
await sync_participant_calendar(db, participant)
except Exception as e:
logger.warning(f"Calendar sync failed for {participant.email}: {e}")
return participant
@app.delete("/api/participants/{participant_id}")
async def delete_participant(participant_id: UUID, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Participant).where(Participant.id == participant_id)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=404, detail="Participant not found")
await db.delete(participant)
await db.commit()
return {"status": "deleted"}
@app.post("/api/availability", response_model=AvailabilityResponse)
async def get_availability(
request: AvailabilityRequest, db: AsyncSession = Depends(get_db)
):
reference_date = datetime.now(timezone.utc) + timedelta(weeks=request.week_offset)
slots = await calculate_availability(db, request.participant_ids, reference_date)
return {"slots": slots}
@app.post("/api/sync", response_model=SyncResponse)
async def sync_calendars(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Participant))
participants = result.scalars().all()
results = await sync_all_calendars(db, list(participants))
return {"results": results}
@app.post("/api/sync/{participant_id}")
async def sync_participant(participant_id: UUID, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Participant).where(Participant.id == participant_id)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=404, detail="Participant not found")
try:
if participant.ics_url:
count = await sync_participant_calendar(db, participant)
return {"status": "success", "blocks_synced": count}
return {"status": "skipped", "message": "No ICS URL provided"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/schedule")
async def schedule_meeting(
data: ScheduleRequest, db: AsyncSession = Depends(get_db)
):
min_start_time = datetime.now(timezone.utc) + timedelta(hours=2)
if data.start_time.replace(tzinfo=timezone.utc) < min_start_time:
raise HTTPException(
status_code=400,
detail="Meetings must be scheduled at least 2 hours in advance."
)
result = await db.execute(
select(Participant).where(Participant.id.in_(data.participant_ids))
)
participants = result.scalars().all()
if len(participants) != len(data.participant_ids):
raise HTTPException(status_code=400, detail="Some participants not found")
participant_dicts = [
{"name": p.name, "email": p.email} for p in participants
]
email_success = await send_meeting_invite(
participant_dicts,
data.title,
data.description,
data.start_time,
data.end_time
)
zulip_success = await asyncio.to_thread(
send_zulip_notification,
data.title,
data.start_time,
participant_dicts
)
return {
"status": "success",
"email_sent": email_success,
"zulip_sent": zulip_success
}