Token-gescoper iCal-Feed (/absences/ical/<token>.ics), abonnierbar in Outlook/Apple/Google. Anders als der CalDAV-Client (Push nach Nextcloud) pollt der Kalender die URL selbst. Feed zeigt nur die eigenen bestätigten Abwesenheiten des Token-Inhabers. - users.ical_token_hash (SHA-256, rotierbar) + Migration 0041 (nur Spalte, keine RLS-Aenderung; users-Policy deckt neue nullable Spalte ab) - Router ical.py: oeffentlicher Feed (kein JWT) + Token-Verwaltung POST/GET/DELETE /users/me/ical-token (authentifiziert) - ProfilePage: Sektion "Kalender-Abo (iCal)" mit Erzeugen/Rotieren/ Deaktivieren, URL-Anzeige einmalig + Kopieren - test_ical.py: Token-Lifecycle + oeffentlicher Feed (3 Tests) Deployed auf 137 (Migration 0041, 196/196 Tests gruen). 164 ausstehend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
133 lines
5.0 KiB
Python
133 lines
5.0 KiB
Python
"""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)
|