main.py (16951B)
1 from fastapi import FastAPI, Request, Depends, HTTPException 2 from fastapi.staticfiles import StaticFiles 3 from fastapi.responses import FileResponse 4 from sqlalchemy.orm import Session 5 from sqlalchemy.exc import IntegrityError 6 from sqlalchemy import text 7 from datetime import datetime, timedelta 8 import os 9 10 from .database import get_db, engine 11 from .models import Base, UserProfile, Calendar, CalendarMembership, PendingInvitation, Event, EventAttendee 12 from .schemas import ( 13 CalendarCreate, CalendarUpdate, VisibilityUpdate, InviteRequest, AddMemberRequest, 14 EventCreate, EventUpdate, 15 ) 16 17 Base.metadata.create_all(bind=engine) 18 19 20 def _migrate(): 21 with engine.connect() as conn: 22 cols = [row[1] for row in conn.execute(text("PRAGMA table_info(events)"))] 23 if "all_day" not in cols: 24 conn.execute(text("ALTER TABLE events ADD COLUMN all_day BOOLEAN NOT NULL DEFAULT 0")) 25 conn.commit() 26 27 28 _migrate() 29 30 app = FastAPI(title="Calendar") 31 32 STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static") 33 app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") 34 35 36 @app.middleware("http") 37 async def no_cache_static(request: Request, call_next): 38 response = await call_next(request) 39 if request.url.path.startswith("/static/"): 40 response.headers["Cache-Control"] = "no-cache" 41 return response 42 43 44 # ── Auth helpers ────────────────────────────────────────────────────────────── 45 46 def _get_identity(request: Request) -> tuple[str, str, str]: 47 username = request.headers.get("x-authentik-username", "") 48 email = request.headers.get("x-authentik-email", "") 49 display_name = request.headers.get("x-authentik-name", "") or username 50 if not username: 51 raise HTTPException(status_code=401, detail="Not authenticated") 52 return username, email, display_name 53 54 55 def _upsert_display_name(db: Session, username: str, display_name: str) -> None: 56 profile = db.query(UserProfile).filter(UserProfile.username == username).first() 57 if profile: 58 if profile.display_name != display_name: 59 profile.display_name = display_name 60 db.commit() 61 else: 62 db.add(UserProfile(username=username, display_name=display_name)) 63 db.commit() 64 65 66 def _display_name(db: Session, username: str) -> str: 67 profile = db.query(UserProfile).filter(UserProfile.username == username).first() 68 return profile.display_name if profile and profile.display_name else username 69 70 71 def _ensure_personal_calendar(db: Session, username: str) -> None: 72 exists = ( 73 db.query(Calendar) 74 .filter(Calendar.owner_username == username, Calendar.is_personal == True) 75 .first() 76 ) 77 if exists: 78 return 79 cal = Calendar(name="Personal", color="#88C0D0", owner_username=username, is_personal=True) 80 db.add(cal) 81 db.flush() 82 db.add(CalendarMembership(calendar_id=cal.id, username=username, is_visible=True)) 83 db.commit() 84 85 86 def _accept_pending_invitations(db: Session, username: str, email: str) -> None: 87 if not email: 88 return 89 pending = ( 90 db.query(PendingInvitation).filter(PendingInvitation.invitee_email == email).all() 91 ) 92 for inv in pending: 93 try: 94 db.add(CalendarMembership(calendar_id=inv.calendar_id, username=username)) 95 db.flush() 96 except IntegrityError: 97 db.rollback() 98 db.delete(inv) 99 db.commit() 100 101 102 def _require_member(db: Session, calendar_id: int, username: str) -> Calendar: 103 cal = db.query(Calendar).filter(Calendar.id == calendar_id).first() 104 if not cal: 105 raise HTTPException(status_code=404, detail="Calendar not found") 106 is_member = ( 107 db.query(CalendarMembership) 108 .filter( 109 CalendarMembership.calendar_id == calendar_id, 110 CalendarMembership.username == username, 111 ) 112 .first() 113 ) 114 if not is_member: 115 raise HTTPException(status_code=403, detail="Access denied") 116 return cal 117 118 119 def _require_owner(db: Session, calendar_id: int, username: str) -> Calendar: 120 cal = _require_member(db, calendar_id, username) 121 if cal.owner_username != username: 122 raise HTTPException(status_code=403, detail="Only the owner can do this") 123 return cal 124 125 126 # ── Routes ──────────────────────────────────────────────────────────────────── 127 128 @app.get("/", include_in_schema=False) 129 async def root(): 130 return FileResponse(os.path.join(STATIC_DIR, "index.html")) 131 132 133 @app.get("/api/me") 134 def get_me(request: Request, db: Session = Depends(get_db)): 135 username, email, display_name = _get_identity(request) 136 _upsert_display_name(db, username, display_name) 137 _accept_pending_invitations(db, username, email) 138 _ensure_personal_calendar(db, username) 139 return {"username": username, "email": email, "display_name": display_name} 140 141 142 @app.get("/api/calendars") 143 def list_calendars(request: Request, db: Session = Depends(get_db)): 144 username, _, _dn = _get_identity(request) 145 memberships = ( 146 db.query(CalendarMembership) 147 .filter(CalendarMembership.username == username) 148 .all() 149 ) 150 result = [] 151 for m in memberships: 152 cal = m.calendar 153 members = [{"username": x.username, "display_name": _display_name(db, x.username)} for x in cal.memberships] 154 pending_emails = [p.invitee_email for p in cal.pending_invitations] 155 result.append( 156 { 157 "id": cal.id, 158 "name": cal.name, 159 "color": cal.color, 160 "is_personal": cal.is_personal, 161 "is_owner": cal.owner_username == username, 162 "is_visible": m.is_visible, 163 "members": members, 164 "pending_invitations": pending_emails if cal.owner_username == username else [], 165 } 166 ) 167 return result 168 169 170 @app.post("/api/calendars", status_code=201) 171 def create_calendar(body: CalendarCreate, request: Request, db: Session = Depends(get_db)): 172 username, _, _dn = _get_identity(request) 173 cal = Calendar(name=body.name, color=body.color, owner_username=username, is_personal=False) 174 db.add(cal) 175 db.flush() 176 db.add(CalendarMembership(calendar_id=cal.id, username=username, is_visible=True)) 177 for uname in body.invite_usernames: 178 uname = uname.strip() 179 if uname and uname != username: 180 try: 181 db.add(CalendarMembership(calendar_id=cal.id, username=uname, is_visible=True)) 182 db.flush() 183 except IntegrityError: 184 db.rollback() 185 db.commit() 186 db.refresh(cal) 187 return {"id": cal.id, "name": cal.name, "color": cal.color} 188 189 190 @app.patch("/api/calendars/{calendar_id}") 191 def update_calendar( 192 calendar_id: int, 193 body: CalendarUpdate, 194 request: Request, 195 db: Session = Depends(get_db), 196 ): 197 username, _, _dn = _get_identity(request) 198 cal = _require_owner(db, calendar_id, username) 199 if body.name is not None: 200 cal.name = body.name 201 if body.color is not None: 202 cal.color = body.color 203 db.commit() 204 return {"id": cal.id, "name": cal.name, "color": cal.color} 205 206 207 @app.delete("/api/calendars/{calendar_id}", status_code=204) 208 def delete_calendar(calendar_id: int, request: Request, db: Session = Depends(get_db)): 209 username, _, _dn = _get_identity(request) 210 cal = _require_owner(db, calendar_id, username) 211 if cal.is_personal: 212 raise HTTPException(status_code=400, detail="Cannot delete personal calendar") 213 db.delete(cal) 214 db.commit() 215 216 217 @app.patch("/api/calendars/{calendar_id}/visibility") 218 def set_visibility( 219 calendar_id: int, 220 body: VisibilityUpdate, 221 request: Request, 222 db: Session = Depends(get_db), 223 ): 224 username, _, _dn = _get_identity(request) 225 m = ( 226 db.query(CalendarMembership) 227 .filter( 228 CalendarMembership.calendar_id == calendar_id, 229 CalendarMembership.username == username, 230 ) 231 .first() 232 ) 233 if not m: 234 raise HTTPException(status_code=404) 235 m.is_visible = body.is_visible 236 db.commit() 237 return {"is_visible": m.is_visible} 238 239 240 @app.post("/api/calendars/{calendar_id}/invite", status_code=201) 241 def invite_member( 242 calendar_id: int, 243 body: InviteRequest, 244 request: Request, 245 db: Session = Depends(get_db), 246 ): 247 username, _, _dn = _get_identity(request) 248 _require_owner(db, calendar_id, username) 249 email = body.email.strip().lower() 250 try: 251 db.add(PendingInvitation(calendar_id=calendar_id, invitee_email=email)) 252 db.commit() 253 except IntegrityError: 254 db.rollback() 255 raise HTTPException(status_code=409, detail="Already invited") 256 return {"invited": email} 257 258 259 @app.post("/api/calendars/{calendar_id}/members", status_code=201) 260 def add_member( 261 calendar_id: int, 262 body: AddMemberRequest, 263 request: Request, 264 db: Session = Depends(get_db), 265 ): 266 username, _, _dn = _get_identity(request) 267 _require_owner(db, calendar_id, username) 268 target = body.username.strip() 269 if not target: 270 raise HTTPException(status_code=422, detail="Username is required") 271 if target == username: 272 raise HTTPException(status_code=400, detail="Cannot add yourself") 273 try: 274 db.add(CalendarMembership(calendar_id=calendar_id, username=target, is_visible=True)) 275 db.commit() 276 except IntegrityError: 277 db.rollback() 278 raise HTTPException(status_code=409, detail="User is already a member") 279 return {"username": target, "display_name": _display_name(db, target)} 280 281 282 @app.delete("/api/calendars/{calendar_id}/members/{member_username}", status_code=204) 283 def remove_member( 284 calendar_id: int, 285 member_username: str, 286 request: Request, 287 db: Session = Depends(get_db), 288 ): 289 username, _, _dn = _get_identity(request) 290 cal = _require_owner(db, calendar_id, username) 291 if member_username == username: 292 raise HTTPException(status_code=400, detail="Cannot remove yourself as owner") 293 m = ( 294 db.query(CalendarMembership) 295 .filter( 296 CalendarMembership.calendar_id == calendar_id, 297 CalendarMembership.username == member_username, 298 ) 299 .first() 300 ) 301 if m: 302 db.delete(m) 303 db.commit() 304 305 306 @app.delete("/api/calendars/{calendar_id}/invitations/{email}", status_code=204) 307 def revoke_invitation( 308 calendar_id: int, 309 email: str, 310 request: Request, 311 db: Session = Depends(get_db), 312 ): 313 username, _, _dn = _get_identity(request) 314 _require_owner(db, calendar_id, username) 315 inv = ( 316 db.query(PendingInvitation) 317 .filter( 318 PendingInvitation.calendar_id == calendar_id, 319 PendingInvitation.invitee_email == email, 320 ) 321 .first() 322 ) 323 if inv: 324 db.delete(inv) 325 db.commit() 326 327 328 @app.delete("/api/calendars/{calendar_id}/leave", status_code=204) 329 def leave_calendar(calendar_id: int, request: Request, db: Session = Depends(get_db)): 330 username, _, _dn = _get_identity(request) 331 cal = _require_member(db, calendar_id, username) 332 if cal.owner_username == username: 333 raise HTTPException(status_code=400, detail="Owner cannot leave; delete the calendar instead") 334 m = ( 335 db.query(CalendarMembership) 336 .filter( 337 CalendarMembership.calendar_id == calendar_id, 338 CalendarMembership.username == username, 339 ) 340 .first() 341 ) 342 if m: 343 db.delete(m) 344 db.commit() 345 346 347 # ── Events ──────────────────────────────────────────────────────────────────── 348 349 def _parse_dt(s: str) -> datetime: 350 try: 351 return datetime.fromisoformat(s) 352 except ValueError: 353 raise HTTPException(status_code=422, detail=f"Invalid datetime: {s!r}") 354 355 356 @app.get("/api/events") 357 def list_events(year: int, month: int, request: Request, db: Session = Depends(get_db)): 358 username, _, _dn = _get_identity(request) 359 memberships = db.query(CalendarMembership).filter(CalendarMembership.username == username).all() 360 cal_ids = [m.calendar_id for m in memberships] 361 362 start = datetime(year, month, 1) 363 end = datetime(year + 1, 1, 1) if month == 12 else datetime(year, month + 1, 1) 364 365 events = ( 366 db.query(Event) 367 .filter(Event.calendar_id.in_(cal_ids), Event.start_dt < end, Event.end_dt >= start) 368 .all() 369 ) 370 return [ 371 { 372 "id": e.id, 373 "calendar_id": e.calendar_id, 374 "title": e.title, 375 "start_dt": e.start_dt.isoformat(), 376 "end_dt": e.end_dt.isoformat(), 377 "description": e.description, 378 "all_day": e.all_day, 379 "attendees": [a.username for a in e.attendees], 380 } 381 for e in events 382 ] 383 384 385 @app.get("/api/events/upcoming") 386 def list_upcoming_events(request: Request, days: int = 90, db: Session = Depends(get_db)): 387 username, _, _dn = _get_identity(request) 388 memberships = db.query(CalendarMembership).filter(CalendarMembership.username == username).all() 389 cal_ids = [m.calendar_id for m in memberships] 390 391 now = datetime.now() 392 today_start = datetime(now.year, now.month, now.day) 393 cutoff = today_start + timedelta(days=days) 394 395 events = ( 396 db.query(Event) 397 .filter(Event.calendar_id.in_(cal_ids), Event.end_dt >= today_start, Event.start_dt < cutoff) 398 .order_by(Event.start_dt) 399 .all() 400 ) 401 return [ 402 { 403 "id": e.id, 404 "calendar_id": e.calendar_id, 405 "title": e.title, 406 "start_dt": e.start_dt.isoformat(), 407 "end_dt": e.end_dt.isoformat(), 408 "description": e.description, 409 "all_day": e.all_day, 410 "attendees": [a.username for a in e.attendees], 411 } 412 for e in events 413 ] 414 415 416 @app.post("/api/events", status_code=201) 417 def create_event(body: EventCreate, request: Request, db: Session = Depends(get_db)): 418 username, _, _dn = _get_identity(request) 419 _require_member(db, body.calendar_id, username) 420 start = _parse_dt(body.start_dt) 421 end = _parse_dt(body.end_dt) 422 if end <= start: 423 raise HTTPException(status_code=422, detail="end_dt must be after start_dt") 424 425 ev = Event( 426 calendar_id=body.calendar_id, 427 title=body.title, 428 start_dt=start, 429 end_dt=end, 430 description=body.description, 431 all_day=body.all_day, 432 ) 433 db.add(ev) 434 db.flush() 435 for uname in body.attendee_usernames: 436 db.add(EventAttendee(event_id=ev.id, username=uname)) 437 db.commit() 438 db.refresh(ev) 439 return { 440 "id": ev.id, 441 "calendar_id": ev.calendar_id, 442 "title": ev.title, 443 "start_dt": ev.start_dt.isoformat(), 444 "end_dt": ev.end_dt.isoformat(), 445 "description": ev.description, 446 "all_day": ev.all_day, 447 "attendees": [a.username for a in ev.attendees], 448 } 449 450 451 @app.patch("/api/events/{event_id}") 452 def update_event(event_id: int, body: EventUpdate, request: Request, db: Session = Depends(get_db)): 453 username, _, _dn = _get_identity(request) 454 ev = db.query(Event).filter(Event.id == event_id).first() 455 if not ev: 456 raise HTTPException(status_code=404) 457 _require_member(db, ev.calendar_id, username) 458 459 if body.title is not None: 460 ev.title = body.title 461 if body.start_dt is not None: 462 ev.start_dt = _parse_dt(body.start_dt) 463 if body.end_dt is not None: 464 ev.end_dt = _parse_dt(body.end_dt) 465 if body.description is not None: 466 ev.description = body.description 467 if body.all_day is not None: 468 ev.all_day = body.all_day 469 if body.attendee_usernames is not None: 470 for a in ev.attendees: 471 db.delete(a) 472 db.flush() 473 for uname in body.attendee_usernames: 474 db.add(EventAttendee(event_id=ev.id, username=uname)) 475 db.commit() 476 db.refresh(ev) 477 return { 478 "id": ev.id, 479 "calendar_id": ev.calendar_id, 480 "title": ev.title, 481 "start_dt": ev.start_dt.isoformat(), 482 "end_dt": ev.end_dt.isoformat(), 483 "description": ev.description, 484 "all_day": ev.all_day, 485 "attendees": [a.username for a in ev.attendees], 486 } 487 488 489 @app.delete("/api/events/{event_id}", status_code=204) 490 def delete_event(event_id: int, request: Request, db: Session = Depends(get_db)): 491 username, _, _dn = _get_identity(request) 492 ev = db.query(Event).filter(Event.id == event_id).first() 493 if not ev: 494 raise HTTPException(status_code=404) 495 _require_member(db, ev.calendar_id, username) 496 db.delete(ev) 497 db.commit()