calendar

A HTML/PHP project for a self-hosted (family)-calendar.
Log | Files | Refs | README

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