import uuid from datetime import datetime, timezone from typing import List, Optional from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, UploadFile, File from fastapi.responses import Response from pydantic import BaseModel from sqlalchemy.orm import Session import dav_util import ical_io import models import permissions from auth import get_current_user from database import get_db from local_events_util import build_local_event_dict, resolve_creator router = APIRouter() def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() class CalendarCreate(BaseModel): name: str color: str = "#34a853" is_birthday: bool = False birthday_notify_days_before: Optional[int] = None class CalendarUpdate(BaseModel): name: Optional[str] = None color: Optional[str] = None enabled: Optional[bool] = None sidebar_hidden: Optional[bool] = None reminders_enabled: Optional[bool] = None caldav_published: Optional[bool] = None is_birthday: Optional[bool] = None birthday_notify_days_before: Optional[int] = None class EventCreate(BaseModel): calendar_id: int title: str start: str end: str allDay: bool = False location: Optional[str] = None description: Optional[str] = None color: Optional[str] = None rrule: Optional[str] = None private: bool = False reminders: Optional[List[int]] = None # minutes before start (0 = at start) external_uid: Optional[str] = None # stable id for imported entries (Contacts dedup) birth_year: Optional[int] = None # birthday events; NULL = year unknown class EventUpdate(BaseModel): title: Optional[str] = None start: Optional[str] = None end: Optional[str] = None allDay: Optional[bool] = None location: Optional[str] = None description: Optional[str] = None color: Optional[str] = None rrule: Optional[str] = None exdate: Optional[str] = None private: Optional[bool] = None reminders: Optional[List[int]] = None external_uid: Optional[str] = None birth_year: Optional[int] = None class ShareCreate(BaseModel): user_id: int permission: str = "read" def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True, shared_by: Optional[str] = None, permission: Optional[str] = None, color_override: Optional[str] = None, request: Optional[Request] = None) -> dict: d = { "id": cal.id, # A shared calendar is labelled by the person/group it comes from — the # owner's real calendar name must never reach recipients. "name": shared_by if (not owned and shared_by is not None) else cal.name, # A recipient's own colour for a shared calendar wins over the owner's. "color": color_override or cal.color, "enabled": cal.enabled, "sidebar_hidden": bool(cal.sidebar_hidden), "reminders_enabled": bool(cal.reminders_enabled), "caldav_published": bool(cal.caldav_published), "is_birthday": bool(cal.is_birthday), "birthday_notify_days_before": cal.birthday_notify_days_before, "type": "local", "owned": owned, } # Only the owner may publish; expose the subscribe URLs only when active. if owned and cal.caldav_published and cal.dav_token: d["caldav_url"] = dav_util.caldav_url(request, cal.dav_token) if request else None d["caldav_login_url"] = dav_util.caldav_login_url(request) if request else None if shared_by is not None: d["shared_by"] = shared_by if permission is not None: d["permission"] = permission return d def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar, db: Session) -> dict: return build_local_event_dict(ev, cal, creator=resolve_creator(ev)) # ── Calendar CRUD ───────────────────────────────────────── @router.get("/calendars") def list_calendars( request: Request, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): # Map calendar_id -> group name for every group the user belongs to, so we # can flag group calendars as such even when the user owns them (the creator # owns the group calendar — it must still be marked group:true). group_cal_map = { cal_id: name for cal_id, name in ( db.query(models.GroupCalendar.calendar_id, models.Group.name) .join(models.Group, models.Group.id == models.GroupCalendar.group_id) .join(models.GroupMember, models.GroupMember.group_id == models.GroupCalendar.group_id) .filter(models.GroupMember.user_id == current_user.id) .all() ) } # Per-user colour overrides for calendars the user doesn't own (any sharing path). color_prefs = permissions.color_prefs_for(db, current_user.id) # Own calendars own = ( db.query(models.LocalCalendar) .filter(models.LocalCalendar.user_id == current_user.id) .all() ) result = [] for c in own: d = _cal_dict(c, owned=True, request=request) if c.id in group_cal_map: d["group"] = True d["shared_by"] = group_cal_map[c.id] # group name, for labelling result.append(d) seen_ids = {c.id for c in own} # Calendars shared with this user shares = ( db.query(models.CalendarShare) .filter(models.CalendarShare.user_id == current_user.id) .all() ) for share in shares: cal = share.calendar if cal is None or cal.id in seen_ids: continue seen_ids.add(cal.id) owner = db.query(models.User).filter(models.User.id == cal.user_id).first() d = _cal_dict( cal, owned=False, shared_by=(owner.display_name or owner.username) if owner else None, permission=share.permission, color_override=color_prefs.get(cal.id), ) if cal.id in group_cal_map: d["group"] = True result.append(d) # Group calendars reached via membership (read_write) that aren't already # listed, so members can select/see the group calendar. for cal_id, group_name in group_cal_map.items(): if cal_id in seen_ids: continue cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == cal_id).first() if not cal: continue seen_ids.add(cal_id) d = _cal_dict(cal, owned=False, shared_by=group_name, permission="read_write", color_override=color_prefs.get(cal.id)) d["group"] = True result.append(d) # Calendars co-members share into shared groups (group_visible_calendar_id). # Read-only, shown under the owner's name. Deduped against everything above # so a calendar already shared directly / as a group calendar isn't doubled. for cal in permissions.co_member_group_visible_calendars(db, current_user): if cal.id in seen_ids: continue seen_ids.add(cal.id) owner = db.query(models.User).filter(models.User.id == cal.user_id).first() d = _cal_dict( cal, owned=False, shared_by=(owner.display_name or owner.username) if owner else None, permission="read", color_override=color_prefs.get(cal.id), request=request, ) d["group_shared"] = True result.append(d) return result @router.post("/calendars") def create_calendar( data: CalendarCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): # Only ONE birthday calendar per account — hard server guarantee. If the # user already has one, "create birthday calendar" is idempotent: return the # existing one instead of creating a second. if data.is_birthday: existing = ( db.query(models.LocalCalendar) .filter( models.LocalCalendar.user_id == current_user.id, models.LocalCalendar.is_birthday == True, ) .first() ) if existing is not None: return _cal_dict(existing) cal = models.LocalCalendar( user_id=current_user.id, name=data.name, color=data.color, is_birthday=data.is_birthday, birthday_notify_days_before=data.birthday_notify_days_before, ) db.add(cal) db.commit() db.refresh(cal) return _cal_dict(cal) @router.put("/calendars/{calendar_id}") def update_calendar( calendar_id: int, data: CalendarUpdate, request: Request, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): cal = ( db.query(models.LocalCalendar) .filter( models.LocalCalendar.id == calendar_id, models.LocalCalendar.user_id == current_user.id, ) .first() ) if not cal: raise HTTPException(404, "Calendar not found") if data.name is not None: cal.name = data.name if data.color is not None: cal.color = data.color if data.enabled is not None: cal.enabled = data.enabled if data.sidebar_hidden is not None: cal.sidebar_hidden = data.sidebar_hidden if data.reminders_enabled is not None: cal.reminders_enabled = data.reminders_enabled if data.is_birthday is not None: # Never let a second calendar be marked as the birthday calendar. if data.is_birthday and not cal.is_birthday: other = ( db.query(models.LocalCalendar) .filter( models.LocalCalendar.user_id == current_user.id, models.LocalCalendar.is_birthday == True, models.LocalCalendar.id != cal.id, ) .first() ) if other is not None: raise HTTPException(422, "A birthday calendar already exists") cal.is_birthday = data.is_birthday if data.birthday_notify_days_before is not None: # -1 is the client's sentinel for "clear the reminder" (JSON has no way to # send SQL NULL through an Optional that also means "unchanged"). cal.birthday_notify_days_before = ( None if data.birthday_notify_days_before < 0 else data.birthday_notify_days_before ) if data.caldav_published is not None: cal.caldav_published = data.caldav_published if data.caldav_published: # First publish: mint a token + initial ctag so clients can sync. if not cal.dav_token: cal.dav_token = dav_util.new_token() if not cal.dav_ctag: cal.dav_ctag = dav_util.new_tag() else: # Unpublishing revokes access: drop the token so the URL 404s. cal.dav_token = None db.commit() db.refresh(cal) return _cal_dict(cal, owned=True, request=request) class CalendarColorUpdate(BaseModel): color: str @router.put("/calendars/{calendar_id}/color") def set_calendar_color( calendar_id: int, data: CalendarColorUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): """Set a calendar's colour. The owner changes the calendar's colour for everyone; anyone else who can see the calendar (direct share, group calendar, or a co-member's group-visible calendar) sets only their OWN per-user colour. Recipients may recolour but never rename a shared calendar.""" cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == calendar_id).first() if cal is None: raise HTTPException(404, "Calendar not found") if cal.user_id == current_user.id: cal.color = data.color else: if calendar_id not in permissions.readable_local_calendar_ids(db, current_user): raise HTTPException(403, "You cannot access this calendar") pref = ( db.query(models.CalendarColorPref) .filter( models.CalendarColorPref.calendar_id == calendar_id, models.CalendarColorPref.user_id == current_user.id, ) .first() ) if pref: pref.color = data.color else: db.add(models.CalendarColorPref( calendar_id=calendar_id, user_id=current_user.id, color=data.color)) db.commit() return {"ok": True, "color": data.color} @router.post("/calendars/{calendar_id}/dav-token/rotate") def rotate_dav_token( calendar_id: int, request: Request, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): """Issue a fresh CalDAV token — the old subscribe URL stops working.""" cal = ( db.query(models.LocalCalendar) .filter( models.LocalCalendar.id == calendar_id, models.LocalCalendar.user_id == current_user.id, ) .first() ) if not cal: raise HTTPException(404, "Calendar not found") if not cal.caldav_published: raise HTTPException(422, "Calendar is not published") cal.dav_token = dav_util.new_token() cal.dav_ctag = dav_util.new_tag() db.commit() db.refresh(cal) return _cal_dict(cal, owned=True, request=request) @router.delete("/calendars/{calendar_id}") def delete_calendar( calendar_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): cal = ( db.query(models.LocalCalendar) .filter( models.LocalCalendar.id == calendar_id, models.LocalCalendar.user_id == current_user.id, ) .first() ) if not cal: raise HTTPException(404, "Calendar not found") db.delete(cal) db.commit() return {"ok": True} # ── Event CRUD ──────────────────────────────────────────── @router.post("/events") def create_event( data: EventCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): # Owner, shared (read_write), or group-member calendars are writable. cal = permissions.accessible_local_calendar( db, current_user, data.calendar_id, require_write=True ) # Idempotent on external_uid: repeated syncs of the same contact (stable # "contact::") must NEVER create duplicates. If a row # with this external_uid already exists in this calendar, update it in place # instead of inserting a new one. Enforced server-side, so the client can # never duplicate — even without the reconcile read. existing = None if data.external_uid: existing = ( db.query(models.LocalEvent) .filter( models.LocalEvent.calendar_id == cal.id, models.LocalEvent.external_uid == data.external_uid, ) .first() ) reminders = ",".join(str(m) for m in data.reminders) if data.reminders else None if existing is not None: ev = existing ev.title = data.title ev.start = data.start ev.end = data.end ev.all_day = data.allDay ev.location = data.location ev.description = data.description ev.color = data.color ev.rrule = data.rrule ev.is_private = data.private ev.reminders = reminders ev.birth_year = data.birth_year else: ev = models.LocalEvent( calendar_id=cal.id, uid=str(uuid.uuid4()), title=data.title, start=data.start, end=data.end, all_day=data.allDay, location=data.location, description=data.description, color=data.color, rrule=data.rrule, is_private=data.private, reminders=reminders, external_uid=data.external_uid, birth_year=data.birth_year, creator_id=current_user.id, # server-side, never from the client ) db.add(ev) dav_util.bump_dav(cal, ev) db.commit() db.refresh(ev) return _event_dict(ev, cal, db) def _writable_event(db: Session, current_user: models.User, uid: str) -> models.LocalEvent: ev = db.query(models.LocalEvent).filter(models.LocalEvent.uid == uid).first() if not ev: raise HTTPException(404, "Event not found") # Raises 404/403 unless the user may write this event's calendar. permissions.accessible_local_calendar(db, current_user, ev.calendar_id, require_write=True) return ev @router.put("/events/{uid}") def update_event( uid: str, data: EventUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): ev = _writable_event(db, current_user, uid) if data.private is not None: ev.is_private = data.private if data.title is not None: ev.title = data.title if data.start is not None: ev.start = data.start if data.end is not None: ev.end = data.end if data.allDay is not None: ev.all_day = data.allDay if data.location is not None: ev.location = data.location if data.description is not None: ev.description = data.description if data.color is not None: ev.color = data.color if data.rrule is not None: ev.rrule = data.rrule if data.rrule else None if data.exdate is not None: existing = ev.exdate or "" dates = [d for d in existing.split(",") if d] if data.exdate not in dates: dates.append(data.exdate) ev.exdate = ",".join(dates) if data.reminders is not None: ev.reminders = ",".join(str(m) for m in data.reminders) if data.reminders else None if data.external_uid is not None: ev.external_uid = data.external_uid if data.birth_year is not None: # -1 is the client's sentinel for "clear" (year became unknown). ev.birth_year = None if data.birth_year < 0 else data.birth_year dav_util.bump_dav(ev.calendar, ev) db.commit() return {"ok": True} @router.delete("/events/{uid}") def delete_event( uid: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): ev = _writable_event(db, current_user, uid) dav_util.bump_dav(ev.calendar) db.delete(ev) db.commit() return {"ok": True} @router.get("/calendars/{calendar_id}/birthdays") def list_birthday_entries( calendar_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): """Raw (unexpanded) rows of a birthday calendar so a client importer can reconcile against the address book by ``external_uid``. Contact-sourced rows carry an ``external_uid``; manually added birthdays have ``None`` and must be left untouched by the importer.""" cal = permissions.accessible_local_calendar(db, current_user, calendar_id, require_write=True) events = ( db.query(models.LocalEvent) .filter(models.LocalEvent.calendar_id == cal.id) .all() ) out = [] for ev in events: month = day = None try: parts = (ev.start or "")[:10].split("-") if len(parts) == 3: month, day = int(parts[1]), int(parts[2]) except (ValueError, IndexError): pass out.append({ "uid": ev.uid, "external_uid": ev.external_uid, "title": ev.title, "month": month, "day": day, "birth_year": ev.birth_year, }) return out # ── Sharing (owner only) ────────────────────────────────── @router.get("/calendars/{calendar_id}/shares") def list_shares( calendar_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): permissions.is_calendar_owner(db, current_user, calendar_id) shares = ( db.query(models.CalendarShare) .filter(models.CalendarShare.calendar_id == calendar_id) .all() ) out = [] for s in shares: u = db.query(models.User).filter(models.User.id == s.user_id).first() out.append({ "user_id": s.user_id, "display_name": (u.display_name or u.username) if u else None, "permission": s.permission, "created_at": s.created_at, }) return out @router.post("/calendars/{calendar_id}/shares") def add_share( calendar_id: int, data: ShareCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): permissions.is_calendar_owner(db, current_user, calendar_id) if data.permission not in ("read", "read_write"): raise HTTPException(422, "permission must be 'read' or 'read_write'") target = db.query(models.User).filter(models.User.id == data.user_id).first() if not target: raise HTTPException(404, "User not found") if target.id == current_user.id: raise HTTPException(422, "Cannot share a calendar with yourself") share = ( db.query(models.CalendarShare) .filter( models.CalendarShare.calendar_id == calendar_id, models.CalendarShare.user_id == data.user_id, ) .first() ) if share: share.permission = data.permission # update existing else: share = models.CalendarShare( calendar_id=calendar_id, user_id=data.user_id, permission=data.permission, created_at=_now_iso(), ) db.add(share) db.commit() return {"ok": True} @router.delete("/calendars/{calendar_id}/shares/{user_id}") def remove_share( calendar_id: int, user_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): permissions.is_calendar_owner(db, current_user, calendar_id) share = ( db.query(models.CalendarShare) .filter( models.CalendarShare.calendar_id == calendar_id, models.CalendarShare.user_id == user_id, ) .first() ) if not share: raise HTTPException(404, "Share not found") db.delete(share) db.commit() return {"ok": True} # ── iCal Import / Export (local calendars only) ─────────── def _import_ics_into(cal: models.LocalCalendar, raw: bytes, db: Session) -> dict: parsed = ical_io.parse_ics(raw) imported = 0 skipped = 0 errors = list(parsed["errors"]) # local_events.uid is globally unique. Dedupe against the DB AND within this # file — e.g. Nextcloud exports recurring events as several VEVENTs sharing a # UID (RECURRENCE-ID overrides), which would otherwise violate the constraint. seen_uids: set[str] = set() for item in parsed["events"]: uid = item.get("uid") or str(uuid.uuid4()) if uid in seen_uids: skipped += 1 continue seen_uids.add(uid) existing = db.query(models.LocalEvent).filter(models.LocalEvent.uid == uid).first() if existing: skipped += 1 continue ev = models.LocalEvent( calendar_id=cal.id, uid=uid, title=item.get("title") or "(ohne Titel)", start=item["start"], end=item["end"], all_day=item.get("all_day", False), location=item.get("location"), description=item.get("description"), rrule=item.get("rrule"), exdate=item.get("exdate"), creator_name_external=item.get("organizer"), etag=dav_util.new_tag(), ) db.add(ev) imported += 1 if imported: dav_util.bump_dav(cal) try: db.commit() except Exception as exc: db.rollback() raise ValueError(f"Import fehlgeschlagen: {exc}") return {"imported": imported, "skipped": skipped, "errors": errors} # Cap .ics uploads so a huge file can't exhaust memory (read fully into RAM). MAX_ICS_BYTES = 5 * 1024 * 1024 # 5 MB — generous for calendars async def _read_capped(file: UploadFile) -> bytes: raw = await file.read(MAX_ICS_BYTES + 1) if len(raw) > MAX_ICS_BYTES: raise HTTPException(413, "Datei zu groß (max. 5 MB)") return raw @router.post("/calendars/{calendar_id}/import") async def import_calendar( calendar_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): cal = permissions.accessible_local_calendar(db, current_user, calendar_id, require_write=True) raw = await _read_capped(file) try: return _import_ics_into(cal, raw, db) except ValueError as e: raise HTTPException(422, str(e)) @router.post("/import") async def import_generic( file: UploadFile = File(...), calendar_id: Optional[int] = Form(None), create_calendar: bool = Form(False), calendar_name: Optional[str] = Form(None), db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): # Read (capped) first so an oversized upload can't leave an empty calendar. raw = await _read_capped(file) if create_calendar: cal = models.LocalCalendar( user_id=current_user.id, name=(calendar_name or "Importiert")[:120], ) db.add(cal) db.commit() db.refresh(cal) elif calendar_id is not None: cal = permissions.accessible_local_calendar(db, current_user, calendar_id, require_write=True) else: raise HTTPException(422, "Provide calendar_id or create_calendar=true") try: result = _import_ics_into(cal, raw, db) except ValueError as e: raise HTTPException(422, str(e)) result["calendar_id"] = cal.id return result @router.get("/calendars/{calendar_id}/export") def export_calendar( calendar_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): cal = permissions.accessible_local_calendar(db, current_user, calendar_id) events = ( db.query(models.LocalEvent) .filter(models.LocalEvent.calendar_id == cal.id) .all() ) # Resolve creator display names for ORGANIZER. name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()} ics = ical_io.build_ics(cal, events, name_cache=name_cache) safe_name = "".join(c for c in cal.name if c.isalnum() or c in (" ", "-", "_")).strip() or "calendar" return Response( content=ics, media_type="text/calendar", headers={"Content-Disposition": f'attachment; filename="{safe_name}.ics"'}, )