""" BAN YARO — WebCal / iCal-Feed Kalender-Abo für Gesundheits-Erinnerungen, Events, Gassi-Treffen und Sittings. Endpunkte: GET /api/webcal/token → liefert (oder erzeugt) den persönlichen Kalender-Token GET /api/webcal/{token}.ics → iCal-Feed (kein Auth, Token ist das Geheimnis) """ import secrets from datetime import datetime, date, timedelta, timezone from fastapi import APIRouter, HTTPException from fastapi.responses import Response from auth import get_current_user from database import db from fastapi import Depends router = APIRouter() # ------------------------------------------------------------------ # Hilfsfunktionen — RFC-5545-konformes iCal # ------------------------------------------------------------------ def _esc(text: str) -> str: """Escape-Sonderzeichen für iCal-Textwerte.""" if not text: return '' return (text .replace('\\', '\\\\') .replace(',', '\\,') .replace(';', '\\;') .replace('\n', '\\n') .replace('\r', '')) def _fold(line: str) -> str: """Zeilenumbruch nach max. 75 Bytes (RFC 5545 §3.1).""" encoded = line.encode('utf-8') if len(encoded) <= 75: return line parts = [] while True: chunk = line.encode('utf-8') if len(chunk) <= 75: parts.append(line) break # Schneide bei max. 75 Bytes ab, ohne Multibyte-Zeichen zu zerreißen i = 75 while len(line[:i].encode('utf-8')) > 75: i -= 1 parts.append(line[:i]) line = ' ' + line[i:] return '\r\n'.join(parts) def _date_str(d: str) -> str: """YYYY-MM-DD → YYYYMMDD""" return d.replace('-', '') def _datetime_str(d: str, t: str | None = None) -> str: """Datum + optionale Uhrzeit (HH:MM) → iCal-Datetime (lokale Zeit, kein Z).""" base = _date_str(d) if t: hhmm = t.replace(':', '')[:4] return f"{base}T{hhmm}00" return f"{base}T000000" def _uid(prefix: str, entry_id: int) -> str: return f"{prefix}-{entry_id}@banyaro.app" def _vevent(**kwargs) -> str: """Baut einen VEVENT-Block. Bekannte kwargs: uid, dtstart, dtend, summary, description, location, rrule.""" lines = ['BEGIN:VEVENT'] now = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ') lines.append(f'DTSTAMP:{now}') lines.append(f'UID:{kwargs["uid"]}') lines.append(f'DTSTART{kwargs["dtstart"]}') lines.append(f'DTEND{kwargs["dtend"]}') lines.append(_fold(f'SUMMARY:{_esc(kwargs.get("summary", ""))}')) if kwargs.get('description'): lines.append(_fold(f'DESCRIPTION:{_esc(kwargs["description"])}')) if kwargs.get('location'): lines.append(_fold(f'LOCATION:{_esc(kwargs["location"])}')) if kwargs.get('rrule'): lines.append(f'RRULE:{kwargs["rrule"]}') lines.append('END:VEVENT') return '\r\n'.join(lines) # ------------------------------------------------------------------ # Token erzeugen / abrufen # ------------------------------------------------------------------ @router.get("/token") async def get_calendar_token(user=Depends(get_current_user)): """Liefert den persönlichen Kalender-Token; erzeugt ihn beim ersten Aufruf.""" with db() as conn: row = conn.execute( "SELECT calendar_token FROM users WHERE id=?", (user["id"],) ).fetchone() token = row["calendar_token"] if row else None if not token: token = secrets.token_urlsafe(32) conn.execute( "UPDATE users SET calendar_token=? WHERE id=?", (token, user["id"]) ) return {"token": token} @router.delete("/token") async def reset_calendar_token(user=Depends(get_current_user)): """Setzt den Token zurück (invalidiert alle laufenden Abos).""" token = secrets.token_urlsafe(32) with db() as conn: conn.execute( "UPDATE users SET calendar_token=? WHERE id=?", (token, user["id"]) ) return {"token": token} # ------------------------------------------------------------------ # iCal-Feed # ------------------------------------------------------------------ @router.get("/{token}.ics") async def ical_feed(token: str): """ Öffentlicher iCal-Feed — kein Session-Cookie nötig. Der Token dient als Authentifizierung. """ with db() as conn: user_row = conn.execute( "SELECT id FROM users WHERE calendar_token=?", (token,) ).fetchone() if not user_row: raise HTTPException(404, "Kalender nicht gefunden.") user_id = user_row["id"] dogs = conn.execute( "SELECT id, name FROM dogs WHERE user_id=?", (user_id,) ).fetchall() dog_map = {d["id"]: d["name"] for d in dogs} dog_ids = list(dog_map.keys()) vevents = [] # ── 1. Health-Erinnerungen ───────────────────────────────── if dog_ids: ph = ','.join('?' * len(dog_ids)) health_rows = conn.execute( f"""SELECT h.id, h.typ, h.bezeichnung, h.naechstes, h.notiz, h.intervall_tage, h.dog_id FROM health h WHERE h.dog_id IN ({ph}) AND h.naechstes IS NOT NULL AND h.typ IN ('impfung','entwurmung','medikament','laeufigkeit') ORDER BY h.naechstes""", dog_ids ).fetchall() TYP_LABEL = { 'impfung': 'Impfung', 'entwurmung': 'Entwurmung', 'medikament': 'Medikament', 'laeufigkeit': 'Läufigkeit', } for e in health_rows: dog_name = dog_map.get(e["dog_id"], "Hund") typ_label = TYP_LABEL.get(e["typ"], e["typ"]) summary = f"{typ_label}: {e['bezeichnung']} ({dog_name})" rrule = None if e["intervall_tage"]: rrule = f"FREQ=DAILY;INTERVAL={e['intervall_tage']}" # next-day als DTEND (All-Day-Event) dtend_date = (date.fromisoformat(e["naechstes"]) + timedelta(days=1)).isoformat() vevents.append(_vevent( uid = _uid('health', e["id"]), dtstart = f";VALUE=DATE:{_date_str(e['naechstes'])}", dtend = f";VALUE=DATE:{_date_str(dtend_date)}", summary = summary, description = e["notiz"] or '', rrule = rrule, )) # ── 2. Events (zukünftige) ───────────────────────────────── event_rows = conn.execute( """SELECT id, titel, datum, uhrzeit, ort_name, beschreibung FROM events WHERE user_id=? AND datum >= date('now') ORDER BY datum, uhrzeit""", (user_id,) ).fetchall() for e in event_rows: if e["uhrzeit"]: dtstart = f":{_datetime_str(e['datum'], e['uhrzeit'])}" dtend = f":{_datetime_str(e['datum'], e['uhrzeit'])}" else: next_day = (date.fromisoformat(e["datum"]) + timedelta(days=1)).isoformat() dtstart = f";VALUE=DATE:{_date_str(e['datum'])}" dtend = f";VALUE=DATE:{_date_str(next_day)}" vevents.append(_vevent( uid = _uid('event', e["id"]), dtstart = dtstart, dtend = dtend, summary = e["titel"], location = e["ort_name"] or '', description = e["beschreibung"] or '', )) # ── 3. Gassi-Treffen (erstellt oder beigetreten) ─────────── walk_rows = conn.execute( """SELECT DISTINCT w.id, w.titel, w.datum, w.uhrzeit, w.ort_name FROM walks w LEFT JOIN walk_participants wp ON wp.walk_id = w.id AND wp.user_id = ? WHERE (w.user_id = ? OR wp.user_id = ?) AND w.datum >= date('now') ORDER BY w.datum, w.uhrzeit""", (user_id, user_id, user_id) ).fetchall() for w in walk_rows: if w["uhrzeit"]: dtstart = f":{_datetime_str(w['datum'], w['uhrzeit'])}" dtend = f":{_datetime_str(w['datum'], w['uhrzeit'])}" else: next_day = (date.fromisoformat(w["datum"]) + timedelta(days=1)).isoformat() dtstart = f";VALUE=DATE:{_date_str(w['datum'])}" dtend = f";VALUE=DATE:{_date_str(next_day)}" vevents.append(_vevent( uid = _uid('walk', w["id"]), dtstart = dtstart, dtend = dtend, summary = f"Gassi-Treffen: {w['titel']}", location = w["ort_name"] or '', )) # ── 4. Angenommene Sittings ──────────────────────────────── sitting_rows = conn.execute( """SELECT sr.id, sr.von, sr.bis, u.name AS sitter_name FROM sitting_requests sr JOIN sitters s ON s.id = sr.sitter_id JOIN users u ON u.id = s.user_id WHERE sr.user_id = ? AND sr.status = 'angenommen' AND sr.bis >= date('now') ORDER BY sr.von""", (user_id,) ).fetchall() for s in sitting_rows: dtend_date = (date.fromisoformat(s["bis"]) + timedelta(days=1)).isoformat() vevents.append(_vevent( uid = _uid('sitting', s["id"]), dtstart = f";VALUE=DATE:{_date_str(s['von'])}", dtend = f";VALUE=DATE:{_date_str(dtend_date)}", summary = f"Hundesitting bei {s['sitter_name'] or 'Sitter'}", )) # ── Kalender zusammenbauen ───────────────────────────────────── body = '\r\n'.join(vevents) cal = ( "BEGIN:VCALENDAR\r\n" "VERSION:2.0\r\n" "PRODID:-//Ban Yaro//DE\r\n" "CALSCALE:GREGORIAN\r\n" "METHOD:PUBLISH\r\n" "X-WR-CALNAME:Ban Yaro\r\n" "X-WR-CALDESC:Impfungen\\, Events & Treffen aus Ban Yaro\r\n" "X-WR-TIMEZONE:Europe/Berlin\r\n" + (body + "\r\n" if body else "") + "END:VCALENDAR\r\n" ) return Response( content = cal.encode('utf-8'), media_type = "text/calendar; charset=utf-8", headers = {"Content-Disposition": 'inline; filename="banyaro.ics"'}, )