diff --git a/backend/main.py b/backend/main.py index 4e8cbd1..c975351 100644 --- a/backend/main.py +++ b/backend/main.py @@ -447,7 +447,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "879" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "880" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/routes/profile.py b/backend/routes/profile.py index 783951f..587fff5 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -167,3 +167,177 @@ async def delete_account(user=Depends(get_current_user)): conn.execute("DELETE FROM forum_posts WHERE user_id=?", (uid,)) conn.execute("DELETE FROM users WHERE id=?", (uid,)) return {"status": "deleted"} + + +# ---------------------------------------------------------- +# GET /profile/export — DSGVO Datenexport (Art. 20) +# ---------------------------------------------------------- +@router.get('/export') +async def export_user_data(user=Depends(get_current_user)): + """Gibt alle personenbezogenen Daten des Users als JSON zurück.""" + import json as _json + from datetime import datetime as _dt + from fastapi.responses import Response as _Response + uid = user['id'] + + with db() as conn: + # --- Nutzerprofil --- + u = dict(conn.execute( + "SELECT id, name, email, bio, wohnort, erfahrung, social_link, " + "email_verified, is_premium, subscription_tier, created_at " + "FROM users WHERE id=?", (uid,) + ).fetchone() or {}) + + # --- Hunde --- + dogs_raw = conn.execute( + "SELECT * FROM dogs WHERE user_id=?", (uid,) + ).fetchall() + dogs_out = [] + + for dog in dogs_raw: + did = dog['id'] + d = dict(dog) + + # Tagebuch + diary_rows = conn.execute( + "SELECT id, datum, typ, titel, text, gps_lat, gps_lon, " + "location_name, is_milestone, created_at FROM diary WHERE dog_id=?", + (did,) + ).fetchall() + diary_out = [] + for de in diary_rows: + de_dict = dict(de) + media = conn.execute( + "SELECT url, preview_url, media_type FROM diary_media WHERE diary_id=?", + (de['id'],) + ).fetchall() + de_dict['media'] = [dict(m) for m in media] + diary_out.append(de_dict) + + # Gesundheit + health_rows = conn.execute( + "SELECT id, typ, bezeichnung, datum, naechstes, notiz, " + "schweregrad, reaktion, dosierung, haeufigkeit, " + "tierarzt_name, charge_nr FROM health WHERE dog_id=?", + (did,) + ).fetchall() + health_out = [] + for he in health_rows: + he_dict = dict(he) + media = conn.execute( + "SELECT url, media_type FROM health_media WHERE health_id=?", + (he['id'],) + ).fetchall() + he_dict['media'] = [dict(m) for m in media] + health_out.append(he_dict) + + # Trainingsfortschritt + progress = conn.execute( + "SELECT exercise_id, status, updated_at FROM exercise_progress " + "WHERE dog_id=?", (did,) + ).fetchall() + + # Ausgaben + expenses = conn.execute( + "SELECT datum, betrag, kategorie, notiz, is_recurring " + "FROM expenses WHERE dog_id=?", (did,) + ).fetchall() + + # Verhalten + behavior = conn.execute( + "SELECT datum, uhrzeit, kategorie, intensitaet, trigger, notiz " + "FROM behavior_log WHERE dog_id=?", (did,) + ).fetchall() + + # Versicherung + insurance = conn.execute( + "SELECT anbieter, police_nr, jahresbeitrag, kontakt, ablaufdatum, notizen " + "FROM dog_insurance WHERE dog_id=?", (did,) + ).fetchall() + + # Ernährungs-Profil + ern = conn.execute( + "SELECT futter_typ, marke, kcal_tag, portionen, notizen " + "FROM futter_profil WHERE dog_id=?", (did,) + ).fetchone() + + # Futter-Einträge + futter = conn.execute( + "SELECT datum, uhrzeit, futter_name, futter_typ, menge_g, notiz " + "FROM futter_eintraege WHERE dog_id=?", (did,) + ).fetchall() + + # Futter-Reaktionen + reaktionen = conn.execute( + "SELECT datum, uhrzeit, reaktion_typ, intensitaet, notiz " + "FROM futter_reaktionen WHERE dog_id=?", (did,) + ).fetchall() + + # Routen (via route_dogs) + routes = conn.execute( + "SELECT r.name, r.distanz_km, r.gps_track IS NOT NULL AS hat_track, " + "date(r.created_at) AS datum " + "FROM routes r JOIN route_dogs rd ON rd.route_id=r.id " + "WHERE rd.dog_id=?", (did,) + ).fetchall() + + d['tagebuch'] = diary_out + d['gesundheit'] = [dict(h) for h in health_out] + d['trainingsfortschritt'] = [dict(p) for p in progress] + d['ausgaben'] = [dict(e) for e in expenses] + d['verhaltensprotokoll'] = [dict(b) for b in behavior] + d['versicherung'] = [dict(i) for i in insurance] + d['ernaehrungsprofil'] = dict(ern) if ern else None + d['futter_eintraege'] = [dict(f) for f in futter] + d['futter_reaktionen'] = [dict(r) for r in reaktionen] + d['routen'] = [dict(r) for r in routes] + dogs_out.append(d) + + # --- Forum-Beiträge --- + forum = conn.execute( + "SELECT ft.title, fp.content, fp.created_at, " + "CASE WHEN fp.parent_id IS NULL THEN 'Thread' ELSE 'Antwort' END AS art " + "FROM forum_posts fp " + "LEFT JOIN forum_threads ft ON ft.id = fp.thread_id " + "WHERE fp.user_id=? ORDER BY fp.created_at DESC", + (uid,) + ).fetchall() + + # --- Gassi-Teilnahmen --- + walk_participations = conn.execute( + "SELECT w.titel, w.datum, w.uhrzeit, w.ort_name " + "FROM walk_participants wp JOIN walks w ON w.id=wp.walk_id " + "WHERE wp.user_id=?", (uid,) + ).fetchall() + + # --- Gassi-Fotos --- + walk_photos = conn.execute( + "SELECT wp.url, w.datum AS walk_datum, w.titel AS walk_titel, wp.created_at " + "FROM walk_photos wp JOIN walks w ON w.id=wp.walk_id " + "WHERE wp.user_id=?", (uid,) + ).fetchall() + + # --- Push-Subscriptions (Anzahl, kein raw endpoint) --- + push_count = conn.execute( + "SELECT COUNT(*) FROM push_subscriptions WHERE user_id=?", (uid,) + ).fetchone()[0] + + export = { + "export_erstellt": _dt.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"), + "hinweis": "Dieser Export enthält alle personenbezogenen Daten deines Ban-Yaro-Kontos gemäß Art. 20 DSGVO.", + "profil": u, + "hunde": dogs_out, + "forum_beitraege": [dict(f) for f in forum], + "gassi_teilnahmen": [dict(w) for w in walk_participations], + "gassi_fotos": [dict(p) for p in walk_photos], + "push_subscriptions": push_count, + } + + content = _json.dumps(export, ensure_ascii=False, indent=2, default=str) + today = _dt.utcnow().strftime("%Y-%m-%d") + filename = f"banyaro-export-{today}.json" + return _Response( + content = content, + media_type = "application/json", + headers = {"Content-Disposition": f'attachment; filename="{filename}"'}, + ) diff --git a/backend/static/index.html b/backend/static/index.html index 5b09df8..8711e3b 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -101,9 +101,9 @@ - - - + + +
@@ -583,10 +583,10 @@ - - - - + + + + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 82f2ac3..9c39ece 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 = '879'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '880'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/pages/datenschutz.js b/backend/static/js/pages/datenschutz.js index d2a8af8..425536f 100644 --- a/backend/static/js/pages/datenschutz.js +++ b/backend/static/js/pages/datenschutz.js @@ -102,13 +102,20 @@ window.Page_datenschutz = (() => {Als Ausweichlösung bei Nichtverfügbarkeit des lokalen Modells wird Claude Sonnet 4.6 von Anthropic, PBC (San Francisco, USA) genutzt. - In diesem Fall wird ausschließlich der Inhalt deiner Anfrage (Prompt-Text) übermittelt — - keine Account- oder Profildaten. Die Übermittlung in die USA erfolgt auf Basis der - EU-Standardvertragsklauseln (Art. 46 Abs. 2 lit. c DSGVO). + In diesem Fall wird der Inhalt deiner Anfrage übermittelt. Bei Gesundheits- und + Ernährungsberichten kann dies Hundedaten (Name, Rasse, Gewicht, Impfhistorie, + Medikamente, Allergien) als Teil des Anfragetextes umfassen. Die Übermittlung + in die USA erfolgt auf Basis der EU-Standardvertragsklauseln (Art. 46 Abs. 2 lit. c DSGVO). Datenschutzerklärung von Anthropic: anthropic.com/privacy.
++ Die Rassenerkennung per Foto sendet das hochgeladene Bild direkt an + Claude von Anthropic (USA) zur Analyse — es gibt hierfür keinen lokalen Fallback. + Das Foto wird nicht dauerhaft bei Anthropic gespeichert. Rechtsgrundlage: Einwilligung + gem. Art. 6 Abs. 1 lit. a DSGVO durch aktive Nutzung der Funktion. +
Der KI-Trainer analysiert deinen bisherigen Trainingsfortschritt (Übungshistorie, Erfolgsquoten, Streaks) und gibt personalisierte Empfehlungen. @@ -122,19 +129,31 @@ window.Page_datenschutz = (() => { findet nicht statt.
`)} - ${sec('Wetterdaten (Open-Meteo)', ` + ${sec('Wetterdaten & Kartendienste', `- Die Wetter-Funktion übermittelt auf Wunsch deine GPS-Koordinaten einmalig an - Open-Meteo (Österreich, DSGVO-konform), um die lokale - Wettervorhersage abzurufen. Es werden ausschließlich anonyme Koordinaten übertragen — - keine Account- oder Profildaten. Open-Meteo protokolliert keine personenbezogenen - Daten. Die Funktion wird nur aktiv, wenn du deinen Standort im Browser freigibst. + Die Wetter-Funktion übermittelt auf Wunsch deine GPS-Koordinaten serverseitig an + Open-Meteo (Österreich, DSGVO-konform) für die Wettervorhersage. + Für Wetter-Kartenlayer (Regenradar, Temperaturen) werden Kacheln von + OpenWeatherMap (OpenWeather Ltd., UK/USA) geladen — dabei wird + dein Browser direkt kontaktiert. Es werden keine Account-Daten übermittelt. Rechtsgrundlage: Einwilligung gem. Art. 6 Abs. 1 lit. a DSGVO.
- Datenschutzerklärung von Open-Meteo: + Für die automatische Ortsnamens-Ermittlung (z. B. im Wetter-Detail) werden deine + GPS-Koordinaten serverseitig an Nominatim der OpenStreetMap Foundation + (UK) übermittelt. Es werden ausschließlich Koordinaten weitergegeben — keine + personenbezogenen Daten. +
++ Datenschutzerklärung Open-Meteo: open-meteo.com/en/terms + style="${S.a}">open-meteo.com/en/terms · + OpenWeatherMap: + openweathermap.org/privacy-policy · + OpenStreetMap/Nominatim: + osmfoundation.org
`)} ${sec('Routenvorschläge (OpenRouteService)', ` @@ -200,7 +219,16 @@ window.Page_datenschutz = (() => { (Art. 16), Löschung (Art. 17), Einschränkung der Verarbeitung (Art. 18) sowie Datenportabilität (Art. 20). Erteilte Einwilligungen kannst du jederzeit mit Wirkung für die Zukunft widerrufen (Art. 7 Abs. 3 DSGVO). - Zur Ausübung deiner Rechte wende dich per E-Mail an + ++ Datenexport (Art. 20 DSGVO): Du kannst jederzeit unter + Einstellungen → „Meine Daten exportieren" eine vollständige Kopie deiner + gespeicherten Daten als JSON-Datei herunterladen. Der Export enthält Profildaten, + Hundedaten, Tagebuch, Gesundheitseinträge, Trainingsfortschritt, Ausgaben, + Verhaltensprotokoll, Forum-Beiträge und Gassi-Teilnahmen. +
+
+ Zur Ausübung weiterer Rechte wende dich per E-Mail an
hallo@banyaro.app.
Du hast außerdem das Recht, bei der zuständigen Datenschutz-Aufsichtsbehörde
Beschwerde einzulegen:
@@ -212,14 +240,14 @@ window.Page_datenschutz = (() => {
${sec('Speicherdauer', `
- Deine Daten werden gelöscht, sobald du deinen Account löschst. Server-Logs - werden nach 30 Tagen automatisch gelöscht. Öffentlich gepostete Inhalte - (Forenbeiträge, Giftköder-Meldungen) bleiben nach Account-Löschung anonymisiert - erhalten, sofern sie für die Community relevant sind. + Deine Daten werden vollständig gelöscht, sobald du deinen Account löschst — + einschließlich Tagebuch, Gesundheitseinträge, Fotos, Forenbeiträge und Hundeprofil. + Es gibt keine anonymisierte Weiterverarbeitung deiner Inhalte nach Account-Löschung. + Server-Logs werden nach 30 Tagen rotiert.
`)}- Stand: Mai 2026 + Stand: Mai 2026 · Version 2
diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 73e8ee5..8767cc6 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -294,6 +294,15 @@ window.Page_settings = (() => { Abmelden +