From 289158b2cd84b13890f558dd25a9c679115e2897 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 19 Apr 2026 10:29:21 +0200 Subject: [PATCH] =?UTF-8?q?Feature:=20Gasthund-Zugang=20f=C3=BCr=20Sitter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sitting_subscriptions Tabelle (dog_id, owner_id, sitter_id, valid_until) - POST/DELETE/GET /api/sitting-access — Zugang gewähren/widerrufen/auflisten - GET /api/dogs gibt Gasthunde zurück (is_guest=True, sitting_until, owner_name) - Diary POST erlaubt Sitter-Schreibzugang; PATCH/DELETE nur für Besitzer - Dog-Switcher: GAST-Badge bei fremden Hunden - Dog-Profil: Sitter-Zugang-Sektion (nur für Besitzer), Freund auswählen + Datum - Diary Detail-View: Bearbeiten-Button für Gasthunde ausgeblendet --- backend/database.py | 16 +++ backend/main.py | 2 + backend/routes/diary.py | 70 +++++++++++-- backend/routes/dogs.py | 25 ++++- backend/routes/sitting_access.py | 72 +++++++++++++ backend/static/js/api.js | 11 +- backend/static/js/app.js | 9 +- backend/static/js/pages/diary.js | 2 +- backend/static/js/pages/dog-profile.js | 136 +++++++++++++++++++++++-- backend/static/sw.js | 2 +- 10 files changed, 327 insertions(+), 18 deletions(-) create mode 100644 backend/routes/sitting_access.py diff --git a/backend/database.py b/backend/database.py index 193d41d..621ec5f 100644 --- a/backend/database.py +++ b/backend/database.py @@ -803,6 +803,22 @@ def _migrate(conn_factory): """) logger.info("Migration: health_media Tabelle bereit.") + # Gasthund-Zugang: Sitter darf temporär für Hund schreiben + conn.executescript(""" + CREATE TABLE IF NOT EXISTS sitting_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + owner_id INTEGER NOT NULL REFERENCES users(id), + sitter_id INTEGER NOT NULL REFERENCES users(id), + valid_until TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + UNIQUE(dog_id, sitter_id) + ); + CREATE INDEX IF NOT EXISTS idx_sitting_sub_sitter ON sitting_subscriptions(sitter_id, valid_until); + CREATE INDEX IF NOT EXISTS idx_sitting_sub_owner ON sitting_subscriptions(owner_id); + """) + logger.info("Migration: sitting_subscriptions Tabelle bereit.") + # Walk-Einladungen (RSVP) conn.executescript(""" CREATE TABLE IF NOT EXISTS walk_invitations ( diff --git a/backend/main.py b/backend/main.py index b4a3b71..8eeda81 100644 --- a/backend/main.py +++ b/backend/main.py @@ -95,6 +95,7 @@ from routes.widget import router as widget_router from routes.notifications import router as notifications_router from routes.services import router as services_router from routes.ratings import router as ratings_router +from routes.sitting_access import router as sitting_access_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -127,6 +128,7 @@ app.include_router(widget_router, prefix="/api/widget", tags=["Widget" app.include_router(notifications_router, prefix="/api/notifications", tags=["Notifications"]) app.include_router(services_router, prefix="/api/services", tags=["Services"]) app.include_router(ratings_router, prefix="/api/ratings", tags=["Ratings"]) +app.include_router(sitting_access_router, prefix="/api/sitting-access", tags=["SittingAccess"]) # ------------------------------------------------------------------ diff --git a/backend/routes/diary.py b/backend/routes/diary.py index 9392d23..d10eb34 100644 --- a/backend/routes/diary.py +++ b/backend/routes/diary.py @@ -54,6 +54,29 @@ def _own_dog(dog_id: int, user_id: int, conn): return dog +def _can_read_dog(dog_id: int, user_id: int, conn): + """Eigener Hund ODER geteilter Hund ODER aktiver Sitter-Zugang (Lesezugriff).""" + dog = conn.execute( + "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id) + ).fetchone() + if not dog: + dog = conn.execute( + """SELECT d.id FROM dogs d + JOIN dog_shares ds ON ds.dog_id = d.id + WHERE d.id=? AND ds.shared_with_id=? AND ds.accepted_at IS NOT NULL""", + (dog_id, user_id) + ).fetchone() + if not dog: + dog = conn.execute( + """SELECT 1 FROM sitting_subscriptions + WHERE dog_id=? AND sitter_id=? AND valid_until >= date('now')""", + (dog_id, user_id) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + return dog + + def _validate_dog_ids(dog_ids: list[int], primary: int, user_id: int, conn) -> list[int]: """Stellt sicher dass alle IDs dem User gehören. Gibt die bereinigte Liste zurück.""" all_ids = list({primary} | set(dog_ids)) @@ -123,7 +146,7 @@ async def list_diary(dog_id: int, limit: int = 20, offset: int = 0, q: Optional[str] = None, milestone: int = 0, user=Depends(get_current_user)): with db() as conn: - _own_dog(dog_id, user["id"], conn) + _can_read_dog(dog_id, user["id"], conn) extra = "AND (d.is_milestone=1 OR d.typ='meilenstein')" if milestone else "" if q: pattern = f"%{q}%" @@ -167,8 +190,24 @@ async def create_diary(dog_id: int, data: DiaryCreate, pass with db() as conn: - _own_dog(dog_id, user["id"], conn) - all_dogs = _validate_dog_ids(data.dog_ids or [], dog_id, user["id"], conn) + # Erlaubnis: eigener Hund ODER aktiver Sitter-Zugang + dog = conn.execute("SELECT user_id FROM dogs WHERE id=?", (dog_id,)).fetchone() + is_owner = dog and dog["user_id"] == user["id"] + is_sitter = conn.execute(""" + SELECT 1 FROM sitting_subscriptions + WHERE dog_id=? AND sitter_id=? AND valid_until >= date('now') + """, (dog_id, user["id"])).fetchone() + if not is_owner and not is_sitter: + # Fallback: shared dog check + try: + _own_dog(dog_id, user["id"], conn) + except HTTPException: + raise HTTPException(403, "Kein Zugriff auf diesen Hund.") + # Sitter darf nur den Gasthund als einzigen Hund eintragen + if is_sitter and not is_owner: + all_dogs = [dog_id] + else: + all_dogs = _validate_dog_ids(data.dog_ids or [], dog_id, user["id"], conn) conn.execute( """INSERT INTO diary @@ -320,7 +359,7 @@ async def nearby_places(dog_id: int, lat: float, lon: float, @router.get("/{dog_id}/diary/{entry_id}") async def get_diary(dog_id: int, entry_id: int, user=Depends(get_current_user)): with db() as conn: - _own_dog(dog_id, user["id"], conn) + _can_read_dog(dog_id, user["id"], conn) row = conn.execute( """SELECT DISTINCT d.* FROM diary d LEFT JOIN diary_dogs dd ON dd.diary_id = d.id @@ -339,7 +378,17 @@ async def get_diary(dog_id: int, entry_id: int, user=Depends(get_current_user)): async def update_diary(dog_id: int, entry_id: int, data: DiaryUpdate, user=Depends(get_current_user)): with db() as conn: - _own_dog(dog_id, user["id"], conn) + # Nur Besitzer des Hundes darf bearbeiten, NICHT Sitter + entry_owner = conn.execute( + "SELECT d.user_id FROM diary dg JOIN dogs d ON d.id=dg.dog_id WHERE dg.id=?", + (entry_id,) + ).fetchone() + if not entry_owner or entry_owner["user_id"] != user["id"]: + # Prüfen ob geteilter Hund (dog_shares) + try: + _own_dog(dog_id, user["id"], conn) + except HTTPException: + raise HTTPException(403, "Nur der Besitzer darf Einträge bearbeiten.") # Prüfen ob Eintrag diesem Hund gehört (direkt oder via diary_dogs) exists = conn.execute( @@ -383,7 +432,16 @@ async def update_diary(dog_id: int, entry_id: int, data: DiaryUpdate, @router.delete("/{dog_id}/diary/{entry_id}", status_code=204) async def delete_diary(dog_id: int, entry_id: int, user=Depends(get_current_user)): with db() as conn: - _own_dog(dog_id, user["id"], conn) + # Nur Besitzer des Hundes darf löschen, NICHT Sitter + entry_owner = conn.execute( + "SELECT d.user_id FROM diary dg JOIN dogs d ON d.id=dg.dog_id WHERE dg.id=?", + (entry_id,) + ).fetchone() + if not entry_owner or entry_owner["user_id"] != user["id"]: + try: + _own_dog(dog_id, user["id"], conn) + except HTTPException: + raise HTTPException(403, "Nur der Besitzer darf Einträge löschen.") conn.execute( "DELETE FROM diary WHERE id=? AND dog_id=?", (entry_id, dog_id) ) diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index bfa8517..596a762 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -50,7 +50,30 @@ async def list_dogs(user=Depends(get_current_user)): WHERE ds.shared_with_id = ? AND ds.accepted_at IS NOT NULL""", (user["id"],) ).fetchall() - return [dict(r) for r in own] + [dict(r) for r in shared] + guest_rows = conn.execute(""" + SELECT d.*, ss.id AS sub_id, ss.valid_until AS sitting_until, + u.name AS owner_name, NULL AS shared_by, NULL AS share_role + FROM sitting_subscriptions ss + JOIN dogs d ON d.id = ss.dog_id + JOIN users u ON u.id = ss.owner_id + WHERE ss.sitter_id = ? + AND ss.valid_until >= date('now') + """, (user["id"],)).fetchall() + + result = [] + for r in own: + d = dict(r) + d["is_guest"] = False + result.append(d) + for r in shared: + d = dict(r) + d["is_guest"] = False + result.append(d) + for r in guest_rows: + d = dict(r) + d["is_guest"] = True + result.append(d) + return result @router.post("") diff --git a/backend/routes/sitting_access.py b/backend/routes/sitting_access.py new file mode 100644 index 0000000..b49e681 --- /dev/null +++ b/backend/routes/sitting_access.py @@ -0,0 +1,72 @@ +"""BAN YARO — Gasthund-Zugang (Sitter-Subscriptions)""" + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from database import db +from auth import get_current_user + +router = APIRouter() + + +class AccessCreate(BaseModel): + dog_id: int + sitter_id: int + valid_until: str # 'YYYY-MM-DD' + + +@router.post("", status_code=201) +async def grant_access(data: AccessCreate, user=Depends(get_current_user)): + """Besitzer gewährt Sitter-Zugang zu seinem Hund.""" + with db() as conn: + dog = conn.execute("SELECT id, user_id FROM dogs WHERE id=?", (data.dog_id,)).fetchone() + if not dog or dog["user_id"] != user["id"]: + raise HTTPException(403, "Nicht dein Hund.") + conn.execute(""" + INSERT INTO sitting_subscriptions (dog_id, owner_id, sitter_id, valid_until) + VALUES (?, ?, ?, ?) + ON CONFLICT(dog_id, sitter_id) DO UPDATE SET valid_until=excluded.valid_until + """, (data.dog_id, user["id"], data.sitter_id, data.valid_until)) + return {"ok": True} + + +@router.delete("/{sub_id}") +async def revoke_access(sub_id: int, user=Depends(get_current_user)): + """Besitzer widerruft Zugang (oder Sitter meldet sich selbst ab).""" + with db() as conn: + row = conn.execute("SELECT * FROM sitting_subscriptions WHERE id=?", (sub_id,)).fetchone() + if not row: + raise HTTPException(404, "Nicht gefunden.") + if row["owner_id"] != user["id"] and row["sitter_id"] != user["id"]: + raise HTTPException(403, "Kein Zugriff.") + conn.execute("DELETE FROM sitting_subscriptions WHERE id=?", (sub_id,)) + return {"ok": True} + + +@router.get("/my") +async def my_subscriptions(user=Depends(get_current_user)): + """Gibt alle aktiven Gasthunde zurück (als Sitter oder Besitzer).""" + with db() as conn: + rows = conn.execute(""" + SELECT ss.id, ss.dog_id, ss.valid_until, + d.name AS dog_name, d.foto_url, d.rasse, + d.foto_zoom, d.foto_offset_x, d.foto_offset_y, + u.name AS owner_name + FROM sitting_subscriptions ss + JOIN dogs d ON d.id = ss.dog_id + JOIN users u ON u.id = ss.owner_id + WHERE ss.sitter_id = ? + AND ss.valid_until >= date('now') + """, (user["id"],)).fetchall() + granted = conn.execute(""" + SELECT ss.id, ss.dog_id, ss.valid_until, + d.name AS dog_name, + u.name AS sitter_name + FROM sitting_subscriptions ss + JOIN dogs d ON d.id = ss.dog_id + JOIN users u ON u.id = ss.sitter_id + WHERE ss.owner_id = ? AND ss.valid_until >= date('now') + """, (user["id"],)).fetchall() + return { + "as_sitter": [dict(r) for r in rows], + "as_owner": [dict(r) for r in granted], + } diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 21bf4fa..03f8efd 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -510,6 +510,15 @@ const API = (() => { deactivate(id) { return del(`/services/${id}`); }, }; + // ---------------------------------------------------------- + // GASTHUND-ZUGANG + // ---------------------------------------------------------- + const sittingAccess = { + grant: (data) => post('/sitting-access', data), + revoke: (id) => del(`/sitting-access/${id}`), + my: () => get('/sitting-access/my'), + }; + const importData = { notestation(dogId, file) { const fd = new FormData(); @@ -542,7 +551,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, webcal, importData, sharing, widget, notifications, services, ratings, + friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, subscribeToPush, getLocation, APIError, }; diff --git a/backend/static/js/app.js b/backend/static/js/app.js index a70ea69..b49f2b3 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 = '213'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '214'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const App = (() => { @@ -611,8 +611,13 @@ const App = (() => { const titleClass = ctxId === 'sb' ? 'sidebar-logo-text' : 'header-title'; el.innerHTML = ` -
+
${avHtml(dog)} + ${dog.is_guest ? `GAST` : ''}
${UI.escape(dog.name)} ${othersHtml}`; diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js index e704820..0bc4300 100644 --- a/backend/static/js/pages/diary.js +++ b/backend/static/js/pages/diary.js @@ -452,7 +452,7 @@ window.Page_diary = (() => { : ''} ${dogsHtml} ${photo} - + ${!_appState?.activeDog?.is_guest ? `` : ''} `; UI.modal.open({ title: entry.titel || typ.label, body }); diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js index 8ab15e1..8a8b64e 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -177,31 +177,48 @@ window.Page_dog_profile = (() => { ` : ''}
- + ` : ''}
- + ` : ''}
- + ` : ''}
+ + ${dog.user_id === _appState.user?.id ? ` +
+
+
Sitter-Zugang
+
+ Gib einem Freund temporären Schreibzugang für diesen Hund +
+
+
Lade…
+
+ ` : ''} `; // Foto-Editor öffnen document.getElementById('dp-photo-edit-btn')?.addEventListener('click', () => { _showPhotoEditor(dog); }); + + // Sitter-Zugang laden (nur für Besitzer) + if (dog.user_id === _appState.user?.id) { + _loadSittingAccess(dog.id); + } // NFC-Link kopieren document.getElementById('dp-copy-link-btn')?.addEventListener('click', async () => { const url = `https://banyaro.app/hund/${dog.id}`; @@ -238,6 +255,113 @@ window.Page_dog_profile = (() => { // Edit- und Add-Klicks laufen über Event-Delegation in init() — keine direkten Listener nötig. } + // ---------------------------------------------------------- + // SITTER-ZUGANG + // ---------------------------------------------------------- + async function _loadSittingAccess(dogId) { + const wrap = document.getElementById('dp-sitting-access'); + if (!wrap) return; + + try { + const [accessData, friendsData] = await Promise.all([ + API.sittingAccess.my(), + API.friends.list(), + ]); + + const active = (accessData.as_owner || []).filter(s => s.dog_id === dogId); + const friends = (friendsData?.friends || []); + + let activeHtml = ''; + if (active.length) { + activeHtml = active.map(s => ` +
+ +
+ ${_esc(s.sitter_name)} + · bis ${_esc(s.valid_until)} +
+ +
`).join(''); + } + + const friendOptions = friends.length + ? friends.map(f => ``).join('') + : ''; + + const today = new Date().toISOString().slice(0, 10); + const defaultUntil = new Date(Date.now() + 14 * 86400000).toISOString().slice(0, 10); + + wrap.innerHTML = ` + ${activeHtml} + ${friends.length ? ` +
+
Zugang gewähren
+
+
+ + +
+
+ + +
+
+ +
+ ` : `

+ Füge zuerst Freunde hinzu, um ihnen Zugang zu gewähren. +

`} + `; + + // Event-Listener + wrap.querySelectorAll('.sa-revoke-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const subId = parseInt(btn.dataset.subId); + try { + await API.sittingAccess.revoke(subId); + _loadSittingAccess(dogId); + } catch (e) { + UI.toast.error(e.message || 'Fehler beim Widerrufen.'); + } + }); + }); + + document.getElementById('sa-grant-btn')?.addEventListener('click', async () => { + const sitterId = parseInt(document.getElementById('sa-friend-select').value); + const validUntil = document.getElementById('sa-until-input').value; + if (!sitterId) { UI.toast.warning('Bitte einen Freund auswählen.'); return; } + if (!validUntil) { UI.toast.warning('Bitte ein Datum angeben.'); return; } + const btn = document.getElementById('sa-grant-btn'); + UI.setLoading(btn, true); + try { + await API.sittingAccess.grant({ dog_id: dogId, sitter_id: sitterId, valid_until: validUntil }); + UI.toast.success('Sitter-Zugang gewährt.'); + _loadSittingAccess(dogId); + } catch (e) { + UI.setLoading(btn, false); + UI.toast.error(e.message || 'Fehler beim Gewähren.'); + } + }); + + } catch (e) { + if (wrap) wrap.innerHTML = '

Fehler beim Laden.

'; + } + } + function _showChipEdit(dog) { UI.modal.open({ title: 'Transpondernummer', diff --git a/backend/static/sw.js b/backend/static/sw.js index bacdf1a..72da6e1 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v236'; +const CACHE_VERSION = 'by-v237'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten