Feature: Gasthund-Zugang für Sitter

- 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
This commit is contained in:
rene 2026-04-19 10:29:21 +02:00
parent eef787cc72
commit 289158b2cd
10 changed files with 327 additions and 18 deletions

View file

@ -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)
)

View file

@ -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("")

View file

@ -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],
}