"""iCal-Abo-Feed pro Nutzer (read-only, abonnierbar in Outlook/Apple/Google). Anders als der CalDAV-Client (der Events aktiv nach Nextcloud *pusht*), stellt dieser Endpunkt einen abonnierbaren `text/calendar`-Feed bereit: der Kalender des Nutzers pollt die URL selbst. Sicherheit / DSGVO: - Token-gescoped auf GENAU EINEN Nutzer (gehasht in `users.ical_token_hash`). - Feed enthält ausschließlich die eigenen Abwesenheiten dieses Nutzers. - Kein JWT (Kalender-Apps können kein Bearer) → Zugang allein über das geheime, rotierbare Token in der URL. Opt-in: ohne Token kein Feed. - Read-only, kein DB-Write → RLS-Bypass-Kontext (get_db default) bleibt stabil. """ from __future__ import annotations import hashlib import secrets from datetime import date, datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, Response from icalendar import Calendar, Event from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings from app.core.database import get_db from app.core.dependencies import get_current_user from app.models.absence import Absence, AbsenceStatus from app.models.absence_type import AbsenceType from app.models.user import User router = APIRouter(tags=["iCal"]) # Nur bestätigte / laufende Anträge im Kalender zeigen – keine offenen/abgelehnten. _FEED_STATUSES = ( AbsenceStatus.APPROVED, AbsenceStatus.FIRST_APPROVED, AbsenceStatus.CANCELLATION_REQUESTED, ) def _hash_token(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() def _feed_url(token: str) -> str: base = settings.frontend_url.rstrip("/") return f"{base}/api/v1/absences/ical/{token}.ics" # ── Öffentlicher Feed (kein JWT) ────────────────────────────────────────────── @router.get("/absences/ical/{token}.ics") async def ical_feed(token: str, db: AsyncSession = Depends(get_db)) -> Response: user = await db.scalar(select(User).where(User.ical_token_hash == _hash_token(token))) if user is None or not user.is_active: raise HTTPException(status_code=404, detail="Kalender-Feed nicht gefunden.") rows = ( await db.execute( select(Absence, AbsenceType.name, AbsenceType.color) .join(AbsenceType, AbsenceType.id == Absence.type_id) .where( Absence.user_id == user.id, Absence.status.in_(_FEED_STATUSES), ) .order_by(Absence.start_date) ) ).all() cal = Calendar() cal.add("prodid", "-//TimeMaster//iCal Feed//DE") cal.add("version", "2.0") cal.add("x-wr-calname", f"TimeMaster – {user.first_name} {user.last_name}") cal.add("x-published-ttl", "PT12H") now = datetime.now(timezone.utc) for absence, type_name, _color in rows: summary = type_name if absence.status == AbsenceStatus.FIRST_APPROVED: summary = f"{type_name} (vorläufig)" elif absence.status == AbsenceStatus.CANCELLATION_REQUESTED: summary = f"{type_name} (Storno beantragt)" if absence.half_day_start or absence.half_day_end: summary += " ½" ev = Event() ev.add("uid", f"absence-{absence.id}@timemaster") ev.add("dtstart", absence.start_date) ev.add("dtend", absence.end_date + timedelta(days=1)) # DTEND exklusiv ev.add("summary", summary) if absence.note: ev.add("description", absence.note) ev.add("status", "CONFIRMED") ev.add("transp", "TRANSPARENT") ev.add("dtstamp", now) ev.add("last-modified", absence.created_at or now) cal.add_component(ev) return Response( content=cal.to_ical(), media_type="text/calendar; charset=utf-8", headers={"Content-Disposition": 'inline; filename="timemaster.ics"'}, ) # ── Token-Verwaltung (authentifiziert) ──────────────────────────────────────── @router.get("/users/me/ical-token") async def ical_token_status(current_user: User = Depends(get_current_user)) -> dict: """Ist der Feed aktiviert? Das Token selbst wird nie zurückgegeben (nur Hash gespeichert).""" return {"enabled": current_user.ical_token_hash is not None} @router.post("/users/me/ical-token") async def create_or_rotate_ical_token( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> dict: """Erzeugt ein neues Token (rotiert ein bestehendes) und liefert die Feed-URL einmalig zurück.""" token = secrets.token_urlsafe(32) current_user.ical_token_hash = _hash_token(token) await db.commit() return {"enabled": True, "url": _feed_url(token)} @router.delete("/users/me/ical-token", status_code=204) async def delete_ical_token( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> Response: current_user.ical_token_hash = None await db.commit() return Response(status_code=204)