Sprint 11: Freunde & Chat + Phosphor-Icon-Vollmigration

- Freundschaften (pending/accepted), Nutzersuche, Anfragen per Push
- Direktnachrichten mit Polling, iMessage-Stil, Deep-Links aus Push
- Alle Seiten (map, places, diary, health, dog-profile, sitting, knigge,
  forum, wiki, walks) vollständig auf Phosphor-Icons migriert
- Wikidata-Rassen-Scraper (~833 neue Rassen, lokal gespiegelte Fotos)
- TheDogAPI lokal gespiegelt (169 Rassen + Fotos)
- Quiz-Result-Cards horizontal (korrekte Bildproportionen)
- SW by-v89
This commit is contained in:
rene 2026-04-15 21:33:53 +02:00
parent 96bd57f0ad
commit 097295c628
44 changed files with 9980 additions and 300 deletions

191
backend/routes/chat.py Normal file
View file

@ -0,0 +1,191 @@
"""BAN YARO — Direktnachrichten"""
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from database import db
from auth import get_current_user
router = APIRouter()
logger = logging.getLogger(__name__)
def _conv_key(a: int, b: int):
"""Normalisiert Konversations-User-IDs: user_a < user_b."""
return (min(a, b), max(a, b))
@router.get("/conversations")
async def list_conversations(user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
rows = conn.execute("""
SELECT c.id, c.last_msg_at,
CASE WHEN c.user_a_id=? THEN c.user_b_id ELSE c.user_a_id END AS partner_id,
CASE WHEN c.user_a_id=? THEN ub.name ELSE ua.name END AS partner_name,
(SELECT text FROM direct_messages
WHERE conversation_id=c.id AND is_deleted=0
ORDER BY created_at DESC LIMIT 1) AS last_text,
(SELECT COUNT(*) FROM direct_messages
WHERE conversation_id=c.id
AND sender_id != ?
AND is_deleted=0
AND created_at > COALESCE(
CASE WHEN c.user_a_id=? THEN c.a_read_at ELSE c.b_read_at END,
'1970-01-01'
)
) AS unread_count
FROM conversations c
JOIN users ua ON ua.id=c.user_a_id
JOIN users ub ON ub.id=c.user_b_id
WHERE c.user_a_id=? OR c.user_b_id=?
ORDER BY COALESCE(c.last_msg_at, c.created_at) DESC
""", (uid, uid, uid, uid, uid, uid)).fetchall()
return [dict(r) for r in rows]
class StartConvModel(BaseModel):
partner_id: int
@router.post("/conversations", status_code=201)
async def start_conversation(data: StartConvModel, user=Depends(get_current_user)):
uid = user["id"]
if uid == data.partner_id:
raise HTTPException(400, "Du kannst dir selbst keine Nachrichten schicken.")
a, b = _conv_key(uid, data.partner_id)
with db() as conn:
f = conn.execute("""
SELECT 1 FROM friendships
WHERE ((requester_id=? AND addressee_id=?) OR (requester_id=? AND addressee_id=?))
AND status='accepted'
""", (uid, data.partner_id, data.partner_id, uid)).fetchone()
if not f:
raise HTTPException(403, "Ihr seid noch keine Freunde.")
existing = conn.execute(
"SELECT id FROM conversations WHERE user_a_id=? AND user_b_id=?", (a, b)
).fetchone()
if existing:
return {"conversation_id": existing["id"]}
cur = conn.execute(
"INSERT INTO conversations (user_a_id, user_b_id) VALUES (?,?)", (a, b)
)
return {"conversation_id": cur.lastrowid}
@router.get("/conversations/{conv_id}")
async def get_messages(conv_id: int, offset: int = 0, limit: int = 50,
user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
conv = conn.execute(
"SELECT * FROM conversations WHERE id=? AND (user_a_id=? OR user_b_id=?)",
(conv_id, uid, uid)
).fetchone()
if not conv:
raise HTTPException(404, "Konversation nicht gefunden.")
partner_id = conv["user_b_id"] if conv["user_a_id"] == uid else conv["user_a_id"]
partner = conn.execute("SELECT name FROM users WHERE id=?", (partner_id,)).fetchone()
msgs = conn.execute("""
SELECT m.id, m.sender_id, m.text, m.is_deleted, m.created_at,
u.name AS sender_name
FROM direct_messages m
JOIN users u ON u.id=m.sender_id
WHERE m.conversation_id=?
ORDER BY m.created_at ASC
LIMIT ? OFFSET ?
""", (conv_id, limit, offset)).fetchall()
return {
"conversation_id": conv_id,
"partner_id": partner_id,
"partner_name": partner["name"] if partner else "Unbekannt",
"messages": [dict(m) for m in msgs],
}
class SendMsgModel(BaseModel):
text: str
@router.post("/conversations/{conv_id}/messages", status_code=201)
async def send_message(conv_id: int, data: SendMsgModel, user=Depends(get_current_user)):
uid = user["id"]
text = data.text.strip()
if not text:
raise HTTPException(400, "Nachricht darf nicht leer sein.")
if len(text) > 2000:
raise HTTPException(400, "Nachricht zu lang (max. 2000 Zeichen).")
with db() as conn:
conv = conn.execute(
"SELECT * FROM conversations WHERE id=? AND (user_a_id=? OR user_b_id=?)",
(conv_id, uid, uid)
).fetchone()
if not conv:
raise HTTPException(404, "Konversation nicht gefunden.")
partner_id = conv["user_b_id"] if conv["user_a_id"] == uid else conv["user_a_id"]
cur = conn.execute("""
INSERT INTO direct_messages (conversation_id, sender_id, text) VALUES (?,?,?)
""", (conv_id, uid, text))
msg_id = cur.lastrowid
conn.execute(
"UPDATE conversations SET last_msg_at=datetime('now') WHERE id=?",
(conv_id,)
)
try:
from routes.push import send_push_to_user
preview = text[:100] + ("" if len(text) > 100 else "")
send_push_to_user(partner_id, {
"title": f"Nachricht von {user['name']}",
"body": preview,
"type": "chat_message",
"tag": f"chat-{conv_id}",
"data": {"page": "chat", "conversation_id": conv_id},
})
except Exception:
pass
return {"id": msg_id, "ok": True}
@router.post("/conversations/{conv_id}/read")
async def mark_read(conv_id: int, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
conv = conn.execute(
"SELECT * FROM conversations WHERE id=? AND (user_a_id=? OR user_b_id=?)",
(conv_id, uid, uid)
).fetchone()
if not conv:
raise HTTPException(404)
field = "a_read_at" if conv["user_a_id"] == uid else "b_read_at"
conn.execute(
f"UPDATE conversations SET {field}=datetime('now') WHERE id=?",
(conv_id,)
)
return {"ok": True}
@router.delete("/messages/{msg_id}")
async def delete_message(msg_id: int, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
msg = conn.execute(
"SELECT id FROM direct_messages WHERE id=? AND sender_id=?", (msg_id, uid)
).fetchone()
if not msg:
raise HTTPException(404, "Nachricht nicht gefunden.")
conn.execute(
"UPDATE direct_messages SET is_deleted=1, text='[gelöscht]' WHERE id=?",
(msg_id,)
)
return {"ok": True}

View file

@ -7,6 +7,7 @@ from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
from routes.push import send_push_to_user
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
@ -159,3 +160,40 @@ async def public_dog_profile(dog_id: int):
if not dog:
raise HTTPException(404, "Profil nicht gefunden oder nicht öffentlich.")
return dict(dog)
class FoundReport(BaseModel):
message: Optional[str] = None
kontakt: Optional[str] = None
# Gefunden-Meldung (kein Login nötig)
@router.post("/public/{dog_id}/found")
async def report_found(dog_id: int, data: FoundReport = FoundReport()):
with db() as conn:
row = conn.execute(
"""SELECT d.id, d.name, d.user_id
FROM dogs d
WHERE d.id=? AND d.is_public=1""",
(dog_id,)
).fetchone()
if not row:
raise HTTPException(404, "Profil nicht gefunden oder nicht öffentlich.")
dog_name = row["name"]
user_id = row["user_id"]
body = data.message.strip() if data.message and data.message.strip() \
else "Jemand hat deinen Hund gefunden. Öffne die App für Details."
if data.kontakt and data.kontakt.strip():
body += f" Kontakt: {data.kontakt.strip()}"
send_push_to_user(user_id, {
"title": f"🐾 {dog_name} wurde gefunden!",
"body": body,
"data": {"page": "diary", "found": True},
"tag": f"found-{dog_id}",
})
return {"ok": True}

View file

@ -58,14 +58,24 @@ async def list_events(
radius: int = 50000,
typ: Optional[str] = None,
alle: bool = False,
quelle: Optional[str] = None,
):
today = date.today().isoformat()
with db() as conn:
q = "SELECT e.*, u.name AS veranstalter_name FROM events e LEFT JOIN users u ON u.id = e.user_id WHERE e.status = 'aktiv'"
q = """
SELECT e.*,
CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name,
e.quelle
FROM events e
LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0
WHERE e.status = 'aktiv'
"""
if not alle:
q += f" AND e.datum >= '{today}'"
if typ and typ in TYPEN:
q += f" AND e.typ = '{typ}'"
if quelle:
q += f" AND e.quelle = '{quelle}'"
q += " ORDER BY e.datum ASC, e.uhrzeit ASC"
rows = conn.execute(q).fetchall()
@ -85,14 +95,14 @@ async def create_event(data: EventCreate, user=Depends(get_current_user)):
raise HTTPException(400, f"Ungültiger Typ. Erlaubt: {', '.join(TYPEN)}")
with db() as conn:
cur = conn.execute("""
INSERT INTO events (user_id, titel, datum, uhrzeit, lat, lon, ort_name, typ, beschreibung, link)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO events (user_id, titel, datum, uhrzeit, lat, lon, ort_name, typ, beschreibung, link, quelle)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'nutzer')
""", (user['id'], data.titel, data.datum, data.uhrzeit,
data.lat, data.lon, data.ort_name,
data.typ, data.beschreibung, data.link))
row = conn.execute(
"SELECT e.*, u.name AS veranstalter_name FROM events e "
"LEFT JOIN users u ON u.id = e.user_id WHERE e.id = ?",
"SELECT e.*, CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, e.quelle "
"FROM events e LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0 WHERE e.id = ?",
(cur.lastrowid,)
).fetchone()
return dict(row)
@ -105,8 +115,8 @@ async def create_event(data: EventCreate, user=Depends(get_current_user)):
async def get_event(event_id: int):
with db() as conn:
row = conn.execute(
"SELECT e.*, u.name AS veranstalter_name FROM events e "
"LEFT JOIN users u ON u.id = e.user_id WHERE e.id = ?",
"SELECT e.*, CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, e.quelle "
"FROM events e LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0 WHERE e.id = ?",
(event_id,)
).fetchone()
if not row:
@ -123,7 +133,7 @@ async def update_event(event_id: int, data: EventUpdate, user=Depends(get_curren
ev = conn.execute("SELECT * FROM events WHERE id = ?", (event_id,)).fetchone()
if not ev:
raise HTTPException(404, "Event nicht gefunden.")
if ev['user_id'] != user['id']:
if ev['user_id'] == 0 or ev['user_id'] != user['id']:
raise HTTPException(403, "Nur der Veranstalter kann das Event bearbeiten.")
updates = data.model_dump(exclude_none=True)
if updates:
@ -132,8 +142,8 @@ async def update_event(event_id: int, data: EventUpdate, user=Depends(get_curren
cols = ', '.join(f"{k} = ?" for k in updates)
conn.execute(f"UPDATE events SET {cols} WHERE id = ?", [*updates.values(), event_id])
row = conn.execute(
"SELECT e.*, u.name AS veranstalter_name FROM events e "
"LEFT JOIN users u ON u.id = e.user_id WHERE e.id = ?",
"SELECT e.*, CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, e.quelle "
"FROM events e LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0 WHERE e.id = ?",
(event_id,)
).fetchone()
return dict(row)
@ -148,6 +158,6 @@ async def delete_event(event_id: int, user=Depends(get_current_user)):
ev = conn.execute("SELECT * FROM events WHERE id = ?", (event_id,)).fetchone()
if not ev:
raise HTTPException(404, "Event nicht gefunden.")
if ev['user_id'] != user['id']:
if ev['user_id'] == 0 or ev['user_id'] != user['id']:
raise HTTPException(403, "Nur der Veranstalter kann das Event löschen.")
conn.execute("UPDATE events SET status = 'geloescht' WHERE id = ?", (event_id,))

551
backend/routes/forum.py Normal file
View file

@ -0,0 +1,551 @@
"""BAN YARO — Forum (Sprint 11)"""
import os, uuid, json
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user, get_current_user_optional
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
FORUM_DIR = os.path.join(MEDIA_DIR, "forum")
KATEGORIEN = ['allgemein', 'rasse', 'region', 'gesundheit', 'erziehung', 'tauschboerse']
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class ThreadCreate(BaseModel):
kategorie: str = 'allgemein'
titel: str
text: str
class PostCreate(BaseModel):
text: str
class ThreadPatch(BaseModel):
is_pinned: Optional[int] = None
is_locked: Optional[int] = None
class LikeBody(BaseModel):
target_type: str # 'thread' | 'post'
target_id: int
class ReportBody(BaseModel):
target_type: str
target_id: int
grund: str
class LocationBody(BaseModel):
lat: Optional[float] = None
lon: Optional[float] = None
show: bool = False
class ResolveReport(BaseModel):
resolved: int = 1
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _save_upload(file: UploadFile, data: bytes) -> str:
os.makedirs(FORUM_DIR, exist_ok=True)
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
filename = f"{uuid.uuid4().hex}{ext}"
path = os.path.join(FORUM_DIR, filename)
with open(path, "wb") as f:
f.write(data)
return f"/media/forum/{filename}"
def _parse_foto_urls(raw) -> list:
if not raw:
return []
try:
return json.loads(raw)
except Exception:
return []
def _user_liked(conn, user_id: int, target_type: str, target_id: int) -> bool:
if not user_id:
return False
row = conn.execute(
"SELECT 1 FROM forum_likes WHERE user_id=? AND target_type=? AND target_id=?",
(user_id, target_type, target_id)
).fetchone()
return row is not None
# ------------------------------------------------------------------
# GET /api/forum/threads
# ------------------------------------------------------------------
@router.get("/threads")
async def list_threads(
kategorie: Optional[str] = None,
search: Optional[str] = None,
limit: int = 30,
offset: int = 0,
user=Depends(get_current_user_optional),
):
uid = user['id'] if user else None
with db() as conn:
q = """
SELECT t.id, t.kategorie, t.titel,
SUBSTR(t.text, 1, 120) AS text_preview,
t.antworten, t.likes, t.views,
t.is_pinned, t.is_locked, t.foto_urls,
t.created_at, t.user_id,
u.name AS autor_name
FROM forum_threads t
LEFT JOIN users u ON u.id = t.user_id
WHERE t.is_deleted = 0
"""
params = []
if kategorie and kategorie != 'alle':
q += " AND t.kategorie = ?"
params.append(kategorie)
if search:
q += " AND (t.titel LIKE ? OR t.text LIKE ?)"
params.extend([f'%{search}%', f'%{search}%'])
q += " ORDER BY t.is_pinned DESC, t.created_at DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
rows = conn.execute(q, params).fetchall()
result = []
for r in rows:
t = dict(r)
foto_list = _parse_foto_urls(t.get('foto_urls'))
t['foto_preview'] = foto_list[0] if foto_list else None
t['foto_urls'] = foto_list
t['user_liked'] = _user_liked(conn, uid, 'thread', t['id']) if uid else False
result.append(t)
return result
# ------------------------------------------------------------------
# POST /api/forum/threads
# ------------------------------------------------------------------
@router.post("/threads", status_code=201)
async def create_thread(data: ThreadCreate, user=Depends(get_current_user)):
if not data.titel.strip():
raise HTTPException(400, "Titel darf nicht leer sein.")
if not data.text.strip():
raise HTTPException(400, "Text darf nicht leer sein.")
if len(data.text.strip()) < 20:
raise HTTPException(400, "Text muss mindestens 20 Zeichen lang sein.")
if data.kategorie not in KATEGORIEN:
raise HTTPException(400, "Ungültige Kategorie.")
with db() as conn:
cur = conn.execute(
"""INSERT INTO forum_threads (user_id, kategorie, titel, text)
VALUES (?, ?, ?, ?)""",
(user['id'], data.kategorie, data.titel.strip(), data.text.strip())
)
row = conn.execute(
"""SELECT t.*, u.name AS autor_name
FROM forum_threads t
LEFT JOIN users u ON u.id = t.user_id
WHERE t.id = ?""",
(cur.lastrowid,)
).fetchone()
t = dict(row)
t['foto_urls'] = _parse_foto_urls(t.get('foto_urls'))
t['user_liked'] = False
return t
# ------------------------------------------------------------------
# GET /api/forum/threads/{id}
# ------------------------------------------------------------------
@router.get("/threads/{thread_id}")
async def get_thread(thread_id: int, user=Depends(get_current_user_optional)):
uid = user['id'] if user else None
with db() as conn:
thread = conn.execute(
"""SELECT t.*, u.name AS autor_name
FROM forum_threads t
LEFT JOIN users u ON u.id = t.user_id
WHERE t.id = ? AND t.is_deleted = 0""",
(thread_id,)
).fetchone()
if not thread:
raise HTTPException(404, "Thread nicht gefunden.")
# Increment views
conn.execute(
"UPDATE forum_threads SET views = views + 1 WHERE id = ?", (thread_id,)
)
posts = conn.execute(
"""SELECT p.*, u.name AS autor_name
FROM forum_posts p
LEFT JOIN users u ON u.id = p.user_id
WHERE p.thread_id = ?
ORDER BY p.created_at ASC""",
(thread_id,)
).fetchall()
result = dict(thread)
result['foto_urls'] = _parse_foto_urls(result.get('foto_urls'))
result['user_liked'] = _user_liked(conn, uid, 'thread', thread_id) if uid else False
result['posts'] = []
for p in posts:
pd = dict(p)
if pd.get('is_deleted'):
result['posts'].append({
'id': pd['id'],
'thread_id': pd['thread_id'],
'is_deleted': 1,
'created_at': pd['created_at'],
})
else:
pd['foto_urls'] = _parse_foto_urls(pd.get('foto_urls'))
pd['user_liked'] = _user_liked(conn, uid, 'post', pd['id']) if uid else False
result['posts'].append(pd)
return result
# ------------------------------------------------------------------
# DELETE /api/forum/threads/{id}
# ------------------------------------------------------------------
@router.delete("/threads/{thread_id}", status_code=204)
async def delete_thread(thread_id: int, user=Depends(get_current_user)):
with db() as conn:
thread = conn.execute(
"SELECT * FROM forum_threads WHERE id = ?", (thread_id,)
).fetchone()
if not thread:
raise HTTPException(404, "Thread nicht gefunden.")
if thread['user_id'] != user['id'] and not user.get('is_moderator'):
raise HTTPException(403, "Keine Berechtigung.")
conn.execute(
"UPDATE forum_threads SET is_deleted = 1 WHERE id = ?", (thread_id,)
)
# ------------------------------------------------------------------
# PATCH /api/forum/threads/{id} — Moderator: pin/lock
# ------------------------------------------------------------------
@router.patch("/threads/{thread_id}")
async def patch_thread(thread_id: int, data: ThreadPatch, user=Depends(get_current_user)):
if not user.get('is_moderator'):
raise HTTPException(403, "Nur Moderatoren können Threads bearbeiten.")
with db() as conn:
thread = conn.execute(
"SELECT * FROM forum_threads WHERE id = ?", (thread_id,)
).fetchone()
if not thread:
raise HTTPException(404, "Thread nicht gefunden.")
updates = data.model_dump(exclude_none=True)
if updates:
cols = ', '.join(f"{k} = ?" for k in updates)
conn.execute(
f"UPDATE forum_threads SET {cols} WHERE id = ?",
[*updates.values(), thread_id]
)
row = conn.execute(
"""SELECT t.*, u.name AS autor_name
FROM forum_threads t
LEFT JOIN users u ON u.id = t.user_id
WHERE t.id = ?""",
(thread_id,)
).fetchone()
t = dict(row)
t['foto_urls'] = _parse_foto_urls(t.get('foto_urls'))
return t
# ------------------------------------------------------------------
# POST /api/forum/threads/{id}/posts
# ------------------------------------------------------------------
@router.post("/threads/{thread_id}/posts", status_code=201)
async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current_user)):
if not data.text.strip():
raise HTTPException(400, "Text darf nicht leer sein.")
with db() as conn:
thread = conn.execute(
"SELECT id, is_locked, is_deleted FROM forum_threads WHERE id = ?",
(thread_id,)
).fetchone()
if not thread:
raise HTTPException(404, "Thread nicht gefunden.")
if thread['is_locked']:
raise HTTPException(403, "Dieser Thread ist gesperrt.")
if thread['is_deleted']:
raise HTTPException(404, "Thread nicht gefunden.")
cur = conn.execute(
"INSERT INTO forum_posts (thread_id, user_id, text) VALUES (?, ?, ?)",
(thread_id, user['id'], data.text.strip())
)
conn.execute(
"UPDATE forum_threads SET antworten = antworten + 1 WHERE id = ?",
(thread_id,)
)
row = conn.execute(
"""SELECT p.*, u.name AS autor_name
FROM forum_posts p
LEFT JOIN users u ON u.id = p.user_id
WHERE p.id = ?""",
(cur.lastrowid,)
).fetchone()
pd = dict(row)
pd['foto_urls'] = []
pd['user_liked'] = False
return pd
# ------------------------------------------------------------------
# DELETE /api/forum/posts/{id}
# ------------------------------------------------------------------
@router.delete("/posts/{post_id}", status_code=204)
async def delete_post(post_id: int, user=Depends(get_current_user)):
with db() as conn:
post = conn.execute(
"SELECT * FROM forum_posts WHERE id = ?", (post_id,)
).fetchone()
if not post:
raise HTTPException(404, "Beitrag nicht gefunden.")
if post['user_id'] != user['id'] and not user.get('is_moderator'):
raise HTTPException(403, "Keine Berechtigung.")
conn.execute(
"UPDATE forum_posts SET is_deleted = 1 WHERE id = ?", (post_id,)
)
# Antworten-Zähler nur verringern wenn eigener soft-delete (nicht Moderator)
conn.execute(
"UPDATE forum_threads SET antworten = MAX(0, antworten - 1) WHERE id = ?",
(post['thread_id'],)
)
# ------------------------------------------------------------------
# POST /api/forum/threads/{id}/fotos
# ------------------------------------------------------------------
@router.post("/threads/{thread_id}/fotos")
async def upload_thread_foto(
thread_id: int,
file: UploadFile = File(...),
user=Depends(get_current_user),
):
with db() as conn:
thread = conn.execute(
"SELECT * FROM forum_threads WHERE id = ? AND is_deleted = 0",
(thread_id,)
).fetchone()
if not thread:
raise HTTPException(404, "Thread nicht gefunden.")
if thread['user_id'] != user['id'] and not user.get('is_moderator'):
raise HTTPException(403, "Keine Berechtigung.")
existing = _parse_foto_urls(thread['foto_urls'])
if len(existing) >= 5:
raise HTTPException(400, "Maximal 5 Fotos pro Thread.")
data = await file.read()
url = _save_upload(file, data)
existing.append(url)
conn.execute(
"UPDATE forum_threads SET foto_urls = ? WHERE id = ?",
(json.dumps(existing), thread_id)
)
return {"foto_url": url, "foto_urls": existing}
# ------------------------------------------------------------------
# POST /api/forum/posts/{id}/fotos
# ------------------------------------------------------------------
@router.post("/posts/{post_id}/fotos")
async def upload_post_foto(
post_id: int,
file: UploadFile = File(...),
user=Depends(get_current_user),
):
with db() as conn:
post = conn.execute(
"SELECT * FROM forum_posts WHERE id = ? AND is_deleted = 0",
(post_id,)
).fetchone()
if not post:
raise HTTPException(404, "Beitrag nicht gefunden.")
if post['user_id'] != user['id'] and not user.get('is_moderator'):
raise HTTPException(403, "Keine Berechtigung.")
existing = _parse_foto_urls(post['foto_urls'])
if len(existing) >= 5:
raise HTTPException(400, "Maximal 5 Fotos pro Beitrag.")
data = await file.read()
url = _save_upload(file, data)
existing.append(url)
conn.execute(
"UPDATE forum_posts SET foto_urls = ? WHERE id = ?",
(json.dumps(existing), post_id)
)
return {"foto_url": url, "foto_urls": existing}
# ------------------------------------------------------------------
# POST /api/forum/like — Toggle
# ------------------------------------------------------------------
@router.post("/like")
async def toggle_like(data: LikeBody, user=Depends(get_current_user)):
if data.target_type not in ('thread', 'post'):
raise HTTPException(400, "Ungültiger Typ.")
table = f"forum_{data.target_type}s"
with db() as conn:
existing = conn.execute(
"SELECT 1 FROM forum_likes WHERE user_id=? AND target_type=? AND target_id=?",
(user['id'], data.target_type, data.target_id)
).fetchone()
if existing:
conn.execute(
"DELETE FROM forum_likes WHERE user_id=? AND target_type=? AND target_id=?",
(user['id'], data.target_type, data.target_id)
)
conn.execute(
f"UPDATE {table} SET likes = MAX(0, likes - 1) WHERE id = ?",
(data.target_id,)
)
liked = False
else:
conn.execute(
"INSERT OR IGNORE INTO forum_likes (user_id, target_type, target_id) VALUES (?,?,?)",
(user['id'], data.target_type, data.target_id)
)
conn.execute(
f"UPDATE {table} SET likes = likes + 1 WHERE id = ?",
(data.target_id,)
)
liked = True
count_row = conn.execute(
f"SELECT likes FROM {table} WHERE id = ?", (data.target_id,)
).fetchone()
count = count_row['likes'] if count_row else 0
return {"liked": liked, "count": count}
# ------------------------------------------------------------------
# POST /api/forum/report
# ------------------------------------------------------------------
@router.post("/report", status_code=201)
async def report_content(data: ReportBody, user=Depends(get_current_user)):
if data.target_type not in ('thread', 'post'):
raise HTTPException(400, "Ungültiger Typ.")
if not data.grund.strip():
raise HTTPException(400, "Grund darf nicht leer sein.")
with db() as conn:
conn.execute(
"""INSERT INTO forum_reports (user_id, target_type, target_id, grund)
VALUES (?, ?, ?, ?)""",
(user['id'], data.target_type, data.target_id, data.grund.strip())
)
return {"ok": True}
# ------------------------------------------------------------------
# GET /api/forum/reports — Moderator
# ------------------------------------------------------------------
@router.get("/reports")
async def list_reports(user=Depends(get_current_user)):
if not user.get('is_moderator'):
raise HTTPException(403, "Nur Moderatoren.")
with db() as conn:
rows = conn.execute(
"""SELECT r.*, u.name AS melder_name
FROM forum_reports r
LEFT JOIN users u ON u.id = r.user_id
WHERE r.resolved = 0
ORDER BY r.created_at DESC"""
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# PATCH /api/forum/reports/{id} — Moderator: resolve
# ------------------------------------------------------------------
@router.patch("/reports/{report_id}")
async def resolve_report(report_id: int, data: ResolveReport, user=Depends(get_current_user)):
if not user.get('is_moderator'):
raise HTTPException(403, "Nur Moderatoren.")
with db() as conn:
conn.execute(
"UPDATE forum_reports SET resolved = ? WHERE id = ?",
(data.resolved, report_id)
)
return {"ok": True}
# ------------------------------------------------------------------
# GET /api/forum/members/map
# ------------------------------------------------------------------
@router.get("/members/map")
async def members_map():
with db() as conn:
rows = conn.execute(
"""SELECT SUBSTR(name, 1, INSTR(name || ' ', ' ') - 1) AS vorname,
forum_lat AS lat, forum_lon AS lon
FROM users
WHERE forum_show_location = 1
AND forum_lat IS NOT NULL
AND forum_lon IS NOT NULL"""
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# PATCH /api/forum/members/location
# ------------------------------------------------------------------
@router.patch("/members/location")
async def set_member_location(data: LocationBody, user=Depends(get_current_user)):
if data.show and data.lat is not None and data.lon is not None:
# Snap to ~1km grid (2 decimal places ≈ 1.1km)
snapped_lat = round(data.lat, 2)
snapped_lon = round(data.lon, 2)
with db() as conn:
conn.execute(
"""UPDATE users SET forum_lat=?, forum_lon=?, forum_show_location=1
WHERE id=?""",
(snapped_lat, snapped_lon, user['id'])
)
return {"ok": True, "lat": snapped_lat, "lon": snapped_lon}
else:
with db() as conn:
conn.execute(
"UPDATE users SET forum_show_location=0 WHERE id=?",
(user['id'],)
)
return {"ok": True, "show": False}
# ------------------------------------------------------------------
# GET /api/forum/search
# ------------------------------------------------------------------
@router.get("/search")
async def search_forum(q: Optional[str] = None, limit: int = 20):
if not q or len(q.strip()) < 2:
return []
term = f'%{q.strip()}%'
with db() as conn:
rows = conn.execute(
"""SELECT t.id, t.kategorie, t.titel, t.antworten, t.created_at,
u.name AS autor_name,
SUBSTR(t.text, 1, 200) AS text_preview
FROM forum_threads t
LEFT JOIN users u ON u.id = t.user_id
WHERE t.is_deleted = 0
AND (t.titel LIKE ? OR t.text LIKE ?)
ORDER BY t.created_at DESC
LIMIT ?""",
(term, term, limit)
).fetchall()
return [dict(r) for r in rows]

148
backend/routes/friends.py Normal file
View file

@ -0,0 +1,148 @@
"""BAN YARO — Freundschaften"""
import logging
from fastapi import APIRouter, Depends, HTTPException
from database import db
from auth import get_current_user
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/")
async def list_friends(user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
friends = conn.execute("""
SELECT f.id, f.status, f.created_at,
CASE WHEN f.requester_id=? THEN f.addressee_id ELSE f.requester_id END AS friend_id,
u.name AS friend_name
FROM friendships f
JOIN users u ON u.id = CASE WHEN f.requester_id=? THEN f.addressee_id ELSE f.requester_id END
WHERE (f.requester_id=? OR f.addressee_id=?) AND f.status='accepted'
ORDER BY u.name
""", (uid, uid, uid, uid)).fetchall()
incoming = conn.execute("""
SELECT f.id, f.created_at, u.name AS requester_name, u.id AS requester_id
FROM friendships f
JOIN users u ON u.id=f.requester_id
WHERE f.addressee_id=? AND f.status='pending'
ORDER BY f.created_at DESC
""", (uid,)).fetchall()
outgoing = conn.execute("""
SELECT f.id, f.created_at, u.name AS addressee_name, u.id AS addressee_id
FROM friendships f
JOIN users u ON u.id=f.addressee_id
WHERE f.requester_id=? AND f.status='pending'
ORDER BY f.created_at DESC
""", (uid,)).fetchall()
return {
"friends": [dict(r) for r in friends],
"incoming": [dict(r) for r in incoming],
"outgoing": [dict(r) for r in outgoing],
}
@router.get("/search")
async def search_users(q: str = "", user=Depends(get_current_user)):
if len(q.strip()) < 2:
return []
uid = user["id"]
with db() as conn:
rows = conn.execute("""
SELECT u.id, u.name
FROM users u
WHERE u.id != ?
AND u.name LIKE ?
AND NOT EXISTS (
SELECT 1 FROM friendships f
WHERE (f.requester_id=? AND f.addressee_id=u.id)
OR (f.requester_id=u.id AND f.addressee_id=?)
)
LIMIT 20
""", (uid, f"%{q.strip()}%", uid, uid)).fetchall()
return [dict(r) for r in rows]
@router.post("/request/{target_id}", status_code=201)
async def send_request(target_id: int, user=Depends(get_current_user)):
uid = user["id"]
if uid == target_id:
raise HTTPException(400, "Du kannst dich nicht selbst als Freund hinzufügen.")
with db() as conn:
if not conn.execute("SELECT 1 FROM users WHERE id=?", (target_id,)).fetchone():
raise HTTPException(404, "Nutzer nicht gefunden.")
existing = conn.execute("""
SELECT id, status FROM friendships
WHERE (requester_id=? AND addressee_id=?) OR (requester_id=? AND addressee_id=?)
""", (uid, target_id, target_id, uid)).fetchone()
if existing:
if existing["status"] == "accepted":
raise HTTPException(400, "Ihr seid bereits befreundet.")
raise HTTPException(400, "Anfrage bereits vorhanden.")
conn.execute(
"INSERT INTO friendships (requester_id, addressee_id) VALUES (?,?)",
(uid, target_id)
)
try:
from routes.push import send_push_to_user
send_push_to_user(target_id, {
"title": "Neue Freundschaftsanfrage",
"body": f"{user['name']} möchte dein Freund sein.",
"type": "friend_request",
"data": {"page": "friends"},
})
except Exception:
pass
return {"ok": True}
@router.post("/{friendship_id}/accept")
async def accept_request(friendship_id: int, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
f = conn.execute(
"SELECT * FROM friendships WHERE id=? AND addressee_id=? AND status='pending'",
(friendship_id, uid)
).fetchone()
if not f:
raise HTTPException(404, "Anfrage nicht gefunden.")
conn.execute(
"UPDATE friendships SET status='accepted', updated_at=datetime('now') WHERE id=?",
(friendship_id,)
)
return {"ok": True}
@router.post("/{friendship_id}/decline")
async def decline_request(friendship_id: int, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
f = conn.execute("""
SELECT id FROM friendships
WHERE id=? AND (addressee_id=? OR requester_id=?) AND status='pending'
""", (friendship_id, uid, uid)).fetchone()
if not f:
raise HTTPException(404, "Anfrage nicht gefunden.")
conn.execute("DELETE FROM friendships WHERE id=?", (friendship_id,))
return {"ok": True}
@router.delete("/{friend_user_id}")
async def remove_friend(friend_user_id: int, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
conn.execute("""
DELETE FROM friendships
WHERE status='accepted'
AND ((requester_id=? AND addressee_id=?) OR (requester_id=? AND addressee_id=?))
""", (uid, friend_user_id, friend_user_id, uid))
return {"ok": True}

113
backend/routes/knigge.py Normal file
View file

@ -0,0 +1,113 @@
"""BAN YARO — Hunde-Knigge Routes"""
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user, get_current_user_optional
router = APIRouter()
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class VoteRequest(BaseModel):
szenario_id: str
answer: str
class KiRatRequest(BaseModel):
situation: str
# ------------------------------------------------------------------
# POST /api/knigge/vote — Stimme abgeben oder ändern (Auth required)
# ------------------------------------------------------------------
@router.post("/vote")
async def vote(data: VoteRequest, user=Depends(get_current_user)):
if not data.szenario_id or not data.answer:
raise HTTPException(400, "szenario_id und answer sind erforderlich.")
with db() as conn:
# Upsert: vorhandene Stimme ersetzen oder neu anlegen
conn.execute(
"""INSERT INTO knigge_votes (szenario_id, user_id, answer)
VALUES (?, ?, ?)
ON CONFLICT(szenario_id, user_id) DO UPDATE SET answer=excluded.answer""",
(data.szenario_id, user["id"], data.answer),
)
rows = conn.execute(
"""SELECT answer, COUNT(*) as cnt
FROM knigge_votes
WHERE szenario_id=?
GROUP BY answer""",
(data.szenario_id,),
).fetchall()
counts = {r["answer"]: r["cnt"] for r in rows}
return {"counts": counts, "user_answer": data.answer}
# ------------------------------------------------------------------
# GET /api/knigge/votes?szenario_id= — Stimmen abrufen (kein Auth nötig)
# ------------------------------------------------------------------
@router.get("/votes")
async def get_votes(
szenario_id: str = Query(...),
user=Depends(get_current_user_optional),
):
with db() as conn:
rows = conn.execute(
"""SELECT answer, COUNT(*) as cnt
FROM knigge_votes
WHERE szenario_id=?
GROUP BY answer""",
(szenario_id,),
).fetchall()
user_answer = None
if user:
row = conn.execute(
"SELECT answer FROM knigge_votes WHERE szenario_id=? AND user_id=?",
(szenario_id, user["id"]),
).fetchone()
if row:
user_answer = row["answer"]
counts = {r["answer"]: r["cnt"] for r in rows}
return {"counts": counts, "user_answer": user_answer}
# ------------------------------------------------------------------
# POST /api/knigge/ki-rat — KI-Situationsberater (Auth required)
# ------------------------------------------------------------------
@router.post("/ki-rat")
async def ki_rat(data: KiRatRequest, user=Depends(get_current_user)):
from ki import complete, KIUnavailableError, KIPremiumRequired
if not data.situation or not data.situation.strip():
raise HTTPException(400, "Situation darf nicht leer sein.")
system = (
"Du bist ein erfahrener Hundeexperte und Hundetrainer. "
"Deine Aufgabe ist es, Hundebesitzern kurze, praktische Ratschläge zu geben. "
"Antworte immer auf Deutsch, freundlich und verständlich."
)
prompt = (
f"Situation: {data.situation.strip()}\n\n"
"Gib einen kurzen, praktischen Rat (maximal 3 Sätze) was der Hundebesitzer tun sollte."
)
try:
rat = await complete(
prompt,
system=system,
max_tokens=300,
requires_premium=False,
user_is_premium=bool(user.get("is_premium")),
)
return {"rat": rat}
except KIPremiumRequired as e:
raise HTTPException(402, str(e))
except KIUnavailableError as e:
raise HTTPException(503, str(e))

171
backend/routes/lost.py Normal file
View file

@ -0,0 +1,171 @@
"""BAN YARO — Verlorener Hund Routes"""
import os, uuid, math
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
from routes.push import send_push_to_all
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
# ------------------------------------------------------------------
# Haversine-Distanz in Metern
# ------------------------------------------------------------------
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
R = 6_371_000
p1 = math.radians(lat1)
p2 = math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class LostDogCreate(BaseModel):
name: str
rasse: Optional[str] = None
beschreibung: str
lat: float
lon: float
dog_id: Optional[int] = None
# ------------------------------------------------------------------
# GET /api/lost — aktive Meldungen (optional nach Distanz gefiltert)
# ------------------------------------------------------------------
@router.get("")
async def list_lost(lat: Optional[float] = None, lon: Optional[float] = None,
radius_km: float = 25):
with db() as conn:
rows = conn.execute(
"""SELECT l.*, u.name AS melder_name
FROM lost_dogs l
LEFT JOIN users u ON u.id = l.user_id
WHERE l.is_active = 1
ORDER BY l.created_at DESC"""
).fetchall()
results = []
for r in rows:
entry = dict(r)
if lat is not None and lon is not None:
dist = _haversine(lat, lon, entry["lat"], entry["lon"])
if dist > radius_km * 1000:
continue
entry["distanz_m"] = round(dist)
results.append(entry)
if lat is not None and lon is not None:
results.sort(key=lambda x: x.get("distanz_m", 0))
return results
# ------------------------------------------------------------------
# POST /api/lost — Hund vermisst melden (Login erforderlich)
# ------------------------------------------------------------------
@router.post("", status_code=201)
async def report_lost(data: LostDogCreate, user=Depends(get_current_user)):
with db() as conn:
conn.execute(
"""INSERT INTO lost_dogs (user_id, dog_id, name, rasse, beschreibung, lat, lon)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(user["id"], data.dog_id, data.name, data.rasse,
data.beschreibung, data.lat, data.lon)
)
row = conn.execute(
"SELECT * FROM lost_dogs WHERE user_id=? ORDER BY id DESC LIMIT 1",
(user["id"],)
).fetchone()
entry = dict(row)
send_push_to_all({
"type": "lost_dog_alert",
"title": f"🔍 {data.name} wird vermisst!",
"body": f"{data.rasse or 'Hund'} in deiner Nähe vermisst. Hilf bei der Suche!",
"tag": f"lost-{entry['id']}",
"data": {"page": "lost"},
})
return entry
# ------------------------------------------------------------------
# POST /api/lost/{id}/foto — Foto hochladen (Login, eigene Meldung)
# ------------------------------------------------------------------
@router.post("/{lost_id}/foto")
async def upload_foto(
lost_id: int,
file: UploadFile = File(...),
user=Depends(get_current_user),
):
with db() as conn:
entry = conn.execute(
"SELECT id FROM lost_dogs WHERE id=? AND user_id=?",
(lost_id, user["id"])
).fetchone()
if not entry:
raise HTTPException(404, "Meldung nicht gefunden oder keine Berechtigung.")
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
filename = f"lost_{lost_id}_{uuid.uuid4().hex[:8]}{ext}"
path = os.path.join(MEDIA_DIR, "lost", filename)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(await file.read())
foto_url = f"/media/lost/{filename}"
with db() as conn:
conn.execute("UPDATE lost_dogs SET foto_url=? WHERE id=?", (foto_url, lost_id))
return {"foto_url": foto_url}
# ------------------------------------------------------------------
# POST /api/lost/{id}/found — als gefunden markieren (Login, eigene Meldung)
# ------------------------------------------------------------------
@router.post("/{lost_id}/found")
async def mark_found(lost_id: int, user=Depends(get_current_user)):
with db() as conn:
entry = conn.execute(
"SELECT * FROM lost_dogs WHERE id=?", (lost_id,)
).fetchone()
if not entry:
raise HTTPException(404, "Meldung nicht gefunden.")
e = dict(entry)
if e["user_id"] != user["id"] and user.get("rolle") != "admin":
raise HTTPException(403, "Keine Berechtigung.")
conn.execute(
"""UPDATE lost_dogs
SET is_active=0, gefunden_at=datetime('now')
WHERE id=?""",
(lost_id,)
)
return {"ok": True}
# ------------------------------------------------------------------
# DELETE /api/lost/{id} — eigene Meldung löschen (Login)
# ------------------------------------------------------------------
@router.delete("/{lost_id}", status_code=204)
async def delete_lost(lost_id: int, user=Depends(get_current_user)):
with db() as conn:
entry = conn.execute(
"SELECT * FROM lost_dogs WHERE id=?", (lost_id,)
).fetchone()
if not entry:
raise HTTPException(404, "Meldung nicht gefunden.")
e = dict(entry)
if e["user_id"] != user["id"] and user.get("rolle") != "admin":
raise HTTPException(403, "Keine Berechtigung.")
conn.execute("DELETE FROM lost_dogs WHERE id=?", (lost_id,))
return None

185
backend/routes/movies.py Normal file
View file

@ -0,0 +1,185 @@
"""BAN YARO — Hunde-Filme Routes"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
from database import db
from auth import get_current_user, get_current_user_optional
router = APIRouter()
# ------------------------------------------------------------------
# Hardcoded Film-Daten
# ------------------------------------------------------------------
FILME = [
{"id": "lassie", "titel": "Lassie", "jahr": 1943, "genre": "Familie", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Der Klassiker schlechthin. Lassie findet immer nach Hause.", "bild_emoji": "🐕", "bewertung_avg": 4.2},
{"id": "benji", "titel": "Benji", "jahr": 1974, "genre": "Familie", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein herrenloser Hund rettet Kinder aus den Händen von Entführern.", "bild_emoji": "🐾", "bewertung_avg": 4.0},
{"id": "marley-and-me", "titel": "Marley & Ich", "jahr": 2008, "genre": "Drama/Komödie", "hund_rasse": "Labrador", "stirbt_der_hund": True, "beschreibung": "Der chaotischste, aber liebste Labrador der Welt. Achtung: Taschentücher bereithalten.", "bild_emoji": "😭", "bewertung_avg": 4.5},
{"id": "hachiko", "titel": "Hachi: A Dog's Tale", "jahr": 2009, "genre": "Drama", "hund_rasse": "Akita", "stirbt_der_hund": True, "beschreibung": "Basiert auf der wahren Geschichte des treuen Akita Hachikō. Starke emotionale Wirkung.", "bild_emoji": "💔", "bewertung_avg": 4.8},
{"id": "101-dalmatiner", "titel": "101 Dalmatiner", "jahr": 1961, "genre": "Animation/Familie", "hund_rasse": "Dalmatiner", "stirbt_der_hund": False, "beschreibung": "Dalmatiner-Welpen vs. die böse Cruella de Vil. Animationsklassiker.", "bild_emoji": "🐡", "bewertung_avg": 4.3},
{"id": "beethoven", "titel": "Beethoven", "jahr": 1992, "genre": "Familie/Komödie", "hund_rasse": "Bernhardiner", "stirbt_der_hund": False, "beschreibung": "Riesiger Bernhardiner bringt Chaos ins Familienleben. Mehrere Fortsetzungen.", "bild_emoji": "🎵", "bewertung_avg": 3.8},
{"id": "rex", "titel": "Kommissar Rex", "jahr": 1994, "genre": "Krimi/Serie", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Österreichische Krimiserie. Rex löst gemeinsam mit seinem Herrchen Verbrechen.", "bild_emoji": "🔍", "bewertung_avg": 4.1},
{"id": "old-yeller", "titel": "Old Yeller", "jahr": 1957, "genre": "Familie/Drama", "hund_rasse": "Mischling", "stirbt_der_hund": True, "beschreibung": "Amerikanischer Filmklassiker. Berühmtestes Filmende der Hundfilm-Geschichte.", "bild_emoji": "🌾", "bewertung_avg": 4.0},
{"id": "buddy", "titel": "Air Bud", "jahr": 1997, "genre": "Familie/Sport", "hund_rasse": "Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Hund spielt Basketball. Klingt absurd, wurde ein Hit.", "bild_emoji": "🏀", "bewertung_avg": 3.5},
{"id": "john-wick", "titel": "John Wick", "jahr": 2014, "genre": "Action", "hund_rasse": "Beagle", "stirbt_der_hund": True, "beschreibung": "Achtung Spoiler: Der Hund stirbt am Anfang. Das löst die ganze Geschichte aus. Kontroversiell beliebt.", "bild_emoji": "💣", "bewertung_avg": 4.6},
{"id": "isle-of-dogs", "titel": "Isle of Dogs", "jahr": 2018, "genre": "Animation", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Wes Anderson Stopmotion-Meisterwerk. Alle Hunde Japans auf einer Insel verbannt.", "bild_emoji": "🏝️", "bewertung_avg": 4.4},
{"id": "eight-below", "titel": "8 Below", "jahr": 2006, "genre": "Abenteuer/Drama", "hund_rasse": "Schlittenhunde", "stirbt_der_hund": True, "beschreibung": "Basiert auf wahren Ereignissen. Schlittenhunde überleben die Antarktis. Einige nicht.", "bild_emoji": "❄️", "bewertung_avg": 4.3},
]
PROMIS = [
{"name": "Hachikō", "rasse": "Akita Inu", "bekannt_fuer": "9 Jahre lang täglich auf seinen verstorbenen Herrchen am Bahnhof Shibuya gewartet. Statue in Tokio.", "emoji": "🗿"},
{"name": "Rin Tin Tin", "rasse": "Deutscher Schäferhund", "bekannt_fuer": "Filmhund der 1920er-Jahre. Rettete Warner Bros. vor dem Bankrott. Erster Hundestar Hollywoods.", "emoji": "🎬"},
{"name": "Laika", "rasse": "Mischling", "bekannt_fuer": "Erstes Lebewesen im Weltall (Sputnik 2, 1957). Wurde zur sowjetischen Weltraumpionierin.", "emoji": "🚀"},
{"name": "Endal", "rasse": "Labrador", "bekannt_fuer": "Assistenzhund in England. Erster Hund der eine EC-Karte am Geldautomaten benutzte.", "emoji": "💳"},
{"name": "Barry", "rasse": "Bernhardiner", "bekannt_fuer": "Legendärer Rettungshund der Alpen (18001812). Soll 40 Menschen das Leben gerettet haben.", "emoji": "🏔️"},
{"name": "Greyfriars Bobby", "rasse": "Skye Terrier", "bekannt_fuer": "14 Jahre lang das Grab seines Herrchens in Edinburgh bewacht. Statue und Pub benannt nach ihm.", "emoji": ""},
]
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class FilmVoteRequest(BaseModel):
bewertung: int # 15
class HundDesMonatsVoteRequest(BaseModel):
dog_id: int
# ------------------------------------------------------------------
# GET /api/movies/filme — Film-Liste mit optionaler User-Bewertung
# ------------------------------------------------------------------
@router.get("/filme")
async def get_filme(user=Depends(get_current_user_optional)):
user_ratings = {}
community_avgs = {}
with db() as conn:
if user:
rows = conn.execute(
"SELECT film_id, bewertung FROM movie_votes WHERE user_id=?",
(user["id"],),
).fetchall()
user_ratings = {r["film_id"]: r["bewertung"] for r in rows}
avg_rows = conn.execute(
"SELECT film_id, AVG(bewertung) as avg_bew, COUNT(*) as cnt FROM movie_votes GROUP BY film_id"
).fetchall()
community_avgs = {r["film_id"]: {"avg": round(r["avg_bew"], 1), "cnt": r["cnt"]} for r in avg_rows}
result = []
for film in FILME:
f = dict(film)
f["user_rating"] = user_ratings.get(film["id"])
if film["id"] in community_avgs:
f["bewertung_avg"] = community_avgs[film["id"]]["avg"]
f["bewertung_cnt"] = community_avgs[film["id"]]["cnt"]
else:
f["bewertung_cnt"] = 0
result.append(f)
return result
# ------------------------------------------------------------------
# POST /api/movies/filme/{film_id}/vote — Bewertung abgeben (Upsert)
# ------------------------------------------------------------------
@router.post("/filme/{film_id}/vote")
async def vote_film(film_id: str, data: FilmVoteRequest, user=Depends(get_current_user)):
if not any(f["id"] == film_id for f in FILME):
raise HTTPException(404, "Film nicht gefunden.")
if data.bewertung < 1 or data.bewertung > 5:
raise HTTPException(400, "Bewertung muss zwischen 1 und 5 liegen.")
with db() as conn:
conn.execute(
"""INSERT INTO movie_votes (user_id, film_id, bewertung)
VALUES (?, ?, ?)
ON CONFLICT(user_id, film_id) DO UPDATE SET bewertung=excluded.bewertung""",
(user["id"], film_id, data.bewertung),
)
row = conn.execute(
"SELECT AVG(bewertung) as avg_bew, COUNT(*) as cnt FROM movie_votes WHERE film_id=?",
(film_id,),
).fetchone()
return {
"film_id": film_id,
"bewertung_avg": round(row["avg_bew"], 1) if row["avg_bew"] else data.bewertung,
"bewertung_cnt": row["cnt"],
"user_rating": data.bewertung,
}
# ------------------------------------------------------------------
# GET /api/movies/hund-des-monats — Top-Votes des aktuellen Monats
# ------------------------------------------------------------------
@router.get("/hund-des-monats")
async def get_hund_des_monats(user=Depends(get_current_user_optional)):
monat = datetime.now().strftime("%Y-%m")
with db() as conn:
rows = conn.execute(
"""SELECT d.id, d.name, d.rasse, d.foto_url, u.name as besitzer_name,
COUNT(v.id) as stimmen
FROM hund_des_monats_votes v
JOIN dogs d ON d.id = v.dog_id
JOIN users u ON u.id = d.user_id
WHERE v.monat = ?
GROUP BY v.dog_id
ORDER BY stimmen DESC
LIMIT 10""",
(monat,),
).fetchall()
user_vote = None
if user:
row = conn.execute(
"SELECT dog_id FROM hund_des_monats_votes WHERE user_id=? AND monat=?",
(user["id"], monat),
).fetchone()
if row:
user_vote = row["dog_id"]
return {
"monat": monat,
"top": [dict(r) for r in rows],
"user_vote": user_vote,
}
# ------------------------------------------------------------------
# POST /api/movies/hund-des-monats/vote — Abstimmen (Auth required)
# ------------------------------------------------------------------
@router.post("/hund-des-monats/vote")
async def vote_hund_des_monats(data: HundDesMonatsVoteRequest, user=Depends(get_current_user)):
monat = datetime.now().strftime("%Y-%m")
with db() as conn:
# Prüfen ob Hund existiert und entweder dem User gehört oder öffentlich ist
dog = conn.execute(
"SELECT id, user_id, is_public FROM dogs WHERE id=?",
(data.dog_id,),
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
if dog["user_id"] != user["id"] and not dog["is_public"]:
raise HTTPException(403, "Dieser Hund ist nicht öffentlich.")
conn.execute(
"""INSERT INTO hund_des_monats_votes (user_id, dog_id, monat)
VALUES (?, ?, ?)
ON CONFLICT(user_id, monat) DO UPDATE SET dog_id=excluded.dog_id""",
(user["id"], data.dog_id, monat),
)
# Aktuelle Stimmenanzahl für den gewählten Hund
row = conn.execute(
"SELECT COUNT(*) as cnt FROM hund_des_monats_votes WHERE dog_id=? AND monat=?",
(data.dog_id, monat),
).fetchone()
return {"dog_id": data.dog_id, "monat": monat, "stimmen": row["cnt"]}

258
backend/routes/wiki.py Normal file
View file

@ -0,0 +1,258 @@
"""BAN YARO — Hunde-Wiki Routes"""
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from database import db
from auth import get_current_user
router = APIRouter()
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class BerichtCreate(BaseModel):
rasse: str
titel: str
text: str
# ------------------------------------------------------------------
# Hilfsfunktion Quiz-Scoring
# ------------------------------------------------------------------
def _quiz_score(rasse: dict, params: dict) -> int:
score = 0
if params.get("groesse") and rasse["groesse"] == params["groesse"]:
score += 2
# Aktivität: exakt = 2, eine Stufe daneben = 1
aktiv_map = {"niedrig": 0, "mittel": 1, "hoch": 2, "sehr_hoch": 3}
if params.get("aktivitaet"):
a_user = aktiv_map.get(params["aktivitaet"], -1)
a_rasse = aktiv_map.get(rasse["aktivitaet"], -1)
diff = abs(a_user - a_rasse)
if diff == 0:
score += 2
elif diff == 1:
score += 1
# Erfahrung: anfaenger bekommt Bonus für einfache Rassen
erf_map = {"anfaenger": 0, "fortgeschritten": 1, "experte": 2}
if params.get("erfahrung"):
e_user = erf_map.get(params["erfahrung"], -1)
e_rasse = erf_map.get(rasse["erfahrung"], -1)
if e_user >= e_rasse:
score += 2
elif e_user == e_rasse - 1:
score += 1
# Kinder
if params.get("kinder") in ("true", "True", "1"):
if rasse["kinder_geeignet"]:
score += 1
# Wohnung
if params.get("wohnung") in ("true", "True", "1"):
if rasse["wohnung_geeignet"]:
score += 2
elif params.get("wohnung") in ("false", "False", "0"):
if not rasse["wohnung_geeignet"]:
score += 1
return score
# ------------------------------------------------------------------
# GET /api/wiki/stats — Seed-Status
# ------------------------------------------------------------------
@router.get("/stats")
async def get_stats():
with db() as conn:
row = conn.execute("SELECT COUNT(*) as total FROM wiki_rassen").fetchone()
total = row["total"] if row else 0
return {"total_breeds": total, "seeded": total > 0}
# ------------------------------------------------------------------
# GET /api/wiki/rassen — alle Rassen (Übersicht, paginiert)
# ------------------------------------------------------------------
@router.get("/rassen")
async def get_rassen(
search: str = Query(""),
gruppe: str = Query(""),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
):
conditions = []
args = []
if search:
conditions.append("(LOWER(name) LIKE ? OR LOWER(gruppe) LIKE ? OR LOWER(temperament) LIKE ?)")
like = f"%{search.lower()}%"
args += [like, like, like]
if gruppe:
conditions.append("gruppe = ?")
args.append(gruppe)
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
args_paged = args + [limit, offset]
with db() as conn:
rows = conn.execute(f"""
SELECT id, name, gruppe, groesse, aktivitaet, erfahrung,
foto_url, slug, kinder_geeignet, wohnung_geeignet
FROM wiki_rassen
{where}
ORDER BY name ASC
LIMIT ? OFFSET ?
""", args_paged).fetchall()
count_row = conn.execute(f"""
SELECT COUNT(*) as total FROM wiki_rassen {where}
""", args).fetchone()
# Alle Gruppen für Filter-Dropdown
gruppen_rows = conn.execute(
"SELECT DISTINCT gruppe FROM wiki_rassen WHERE gruppe IS NOT NULL ORDER BY gruppe"
).fetchall()
return {
"breeds": [dict(r) for r in rows],
"total": count_row["total"] if count_row else 0,
"gruppen": [r["gruppe"] for r in gruppen_rows],
}
# ------------------------------------------------------------------
# GET /api/wiki/rassen/{slug} — Rasse-Detail + Community-Berichte
# ------------------------------------------------------------------
@router.get("/rassen/{rasse_slug}")
async def get_rasse(rasse_slug: str):
with db() as conn:
rasse = conn.execute(
"SELECT * FROM wiki_rassen WHERE slug = ?", (rasse_slug,)
).fetchone()
if not rasse:
raise HTTPException(404, "Rasse nicht gefunden.")
rows = conn.execute(
"""SELECT wb.id, wb.titel, wb.text, wb.created_at, u.name as autor
FROM wiki_berichte wb
JOIN users u ON u.id = wb.user_id
WHERE wb.rasse = ?
ORDER BY wb.created_at DESC
LIMIT 50""",
(rasse_slug,),
).fetchall()
result = dict(rasse)
result["berichte"] = [dict(r) for r in rows]
return result
# ------------------------------------------------------------------
# POST /api/wiki/berichte — Community-Bericht hinzufügen
# ------------------------------------------------------------------
@router.post("/berichte")
async def create_bericht(data: BerichtCreate, user=Depends(get_current_user)):
# Prüfen ob die Rasse in der DB existiert
with db() as conn:
rasse_row = conn.execute(
"SELECT slug FROM wiki_rassen WHERE slug = ?", (data.rasse,)
).fetchone()
if not rasse_row:
raise HTTPException(400, "Ungültige Rasse.")
if not data.titel.strip():
raise HTTPException(400, "Titel darf nicht leer sein.")
if not data.text.strip():
raise HTTPException(400, "Text darf nicht leer sein.")
with db() as conn:
cur = conn.execute(
"""INSERT INTO wiki_berichte (user_id, rasse, titel, text)
VALUES (?, ?, ?, ?)""",
(user["id"], data.rasse, data.titel.strip(), data.text.strip()),
)
row = conn.execute(
"SELECT wb.id, wb.titel, wb.text, wb.created_at, u.name as autor "
"FROM wiki_berichte wb JOIN users u ON u.id = wb.user_id "
"WHERE wb.id = ?",
(cur.lastrowid,),
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# DELETE /api/wiki/berichte/{id} — Bericht löschen (nur eigene)
# ------------------------------------------------------------------
@router.delete("/berichte/{bericht_id}")
async def delete_bericht(bericht_id: int, user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"SELECT id, user_id FROM wiki_berichte WHERE id = ?",
(bericht_id,),
).fetchone()
if not row:
raise HTTPException(404, "Bericht nicht gefunden.")
if row["user_id"] != user["id"]:
raise HTTPException(403, "Nicht erlaubt.")
conn.execute("DELETE FROM wiki_berichte WHERE id = ?", (bericht_id,))
return {"ok": True}
# ------------------------------------------------------------------
# GET /api/wiki/quiz/result — Quiz-Ergebnis berechnen
# ------------------------------------------------------------------
@router.get("/quiz/result")
async def quiz_result(
groesse: str = Query(""),
aktivitaet: str = Query(""),
erfahrung: str = Query(""),
kinder: str = Query(""),
wohnung: str = Query(""),
):
params = {
"groesse": groesse,
"aktivitaet": aktivitaet,
"erfahrung": erfahrung,
"kinder": kinder,
"wohnung": wohnung,
}
with db() as conn:
rows = conn.execute(
"""SELECT id, name, gruppe, groesse, aktivitaet, erfahrung,
foto_url, slug, kinder_geeignet, wohnung_geeignet,
temperament, bred_for
FROM wiki_rassen
ORDER BY name ASC"""
).fetchall()
rassen = [dict(r) for r in rows]
if not rassen:
return {"results": []}
scored = sorted(
rassen,
key=lambda r: _quiz_score(r, params),
reverse=True,
)
top3 = [
{
"slug": r["slug"],
"name": r["name"],
"gruppe": r["gruppe"],
"groesse": r["groesse"],
"aktivitaet": r["aktivitaet"],
"erfahrung": r["erfahrung"],
"foto_url": r["foto_url"],
"kinder_geeignet": r["kinder_geeignet"],
"wohnung_geeignet":r["wohnung_geeignet"],
"temperament": r["temperament"],
"score": _quiz_score(r, params),
}
for r in scored[:3]
]
return {"results": top3}