From a4f74b6c64257d1bbb6872933059a05cdaeba869 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 16 Apr 2026 22:39:50 +0200 Subject: [PATCH] Sprint 13: WebCal-Abo / Kalender-Integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/webcal/token: erzeugt personl. Kalender-Token (einmalig) - GET /api/webcal/{token}.ics: iCal-Feed mit Health-Erinnerungen, eigenen Events, Gassi-Treffen (erstellt + beigetreten), angenommenen Sittings - RRULE für wiederkehrende Health-Einträge (intervall_tage) - Migration: users.calendar_token (TEXT UNIQUE) - Settings: "Kalender abonnieren" öffnet webcal://-Link + Kopier-Button - api.js: API.webcal.getToken() / resetToken() - SW-Cache: by-v104, APP_VER: 80 --- backend/database.py | 2 + backend/main.py | 2 + backend/routes/webcal.py | 290 ++++++++++++++++++++++++++++ backend/static/index.html | 10 +- backend/static/js/api.js | 10 +- backend/static/js/app.js | 2 +- backend/static/js/pages/settings.js | 52 +++++ backend/static/sw.js | 2 +- 8 files changed, 362 insertions(+), 8 deletions(-) create mode 100644 backend/routes/webcal.py diff --git a/backend/database.py b/backend/database.py index 93d655a..ddaff86 100644 --- a/backend/database.py +++ b/backend/database.py @@ -442,6 +442,8 @@ def _migrate(conn_factory): # Admin: User-Sperre ("users", "is_banned", "INTEGER NOT NULL DEFAULT 0"), ("users", "ban_reason", "TEXT"), + # WebCal: Kalender-Abo-Token + ("users", "calendar_token", "TEXT UNIQUE"), ] with conn_factory() as conn: for table, column, col_type in migrations: diff --git a/backend/main.py b/backend/main.py index 1e309dd..a115694 100644 --- a/backend/main.py +++ b/backend/main.py @@ -71,6 +71,7 @@ from routes.movies import router as movies_router from routes.friends import router as friends_router from routes.chat import router as chat_router from routes.admin import router as admin_router +from routes.webcal import router as webcal_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -94,6 +95,7 @@ app.include_router(movies_router, prefix="/api/movies", tags=["Filme"]) app.include_router(friends_router, prefix="/api/friends", tags=["Freunde"]) app.include_router(chat_router, prefix="/api/chat", tags=["Chat"]) app.include_router(admin_router, prefix="/api/admin", tags=["Admin"]) +app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"]) # ------------------------------------------------------------------ diff --git a/backend/routes/webcal.py b/backend/routes/webcal.py new file mode 100644 index 0000000..7815f77 --- /dev/null +++ b/backend/routes/webcal.py @@ -0,0 +1,290 @@ +""" +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"'}, + ) diff --git a/backend/static/index.html b/backend/static/index.html index f773267..576a25e 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -22,8 +22,8 @@ - - + + @@ -269,9 +269,9 @@ - - - + + + diff --git a/backend/static/js/api.js b/backend/static/js/api.js index ea996ae..51877d4 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -400,6 +400,14 @@ const API = (() => { }); } + // ---------------------------------------------------------- + // WEBCAL + // ---------------------------------------------------------- + const webcal = { + getToken: () => get('/webcal/token'), + resetToken: () => del('/webcal/token'), + }; + // ---------------------------------------------------------- // ERROR-KLASSE // ---------------------------------------------------------- @@ -417,7 +425,7 @@ const API = (() => { get, post, put, patch, del, upload, auth, dogs, diary, health, tieraerzte, poison, places, routes, walks, events, sitting, forum, lost, knigge, weather, push, - friends, chat, + friends, chat, webcal, subscribeToPush, getLocation, APIError, }; diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 1631e17..c9fecc7 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '79'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '80'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const App = (() => { diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index ad90261..8c54919 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -78,6 +78,12 @@ window.Page_settings = (() => { Push-Benachrichtigungen +