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:
parent
96bd57f0ad
commit
097295c628
44 changed files with 9980 additions and 300 deletions
191
backend/routes/chat.py
Normal file
191
backend/routes/chat.py
Normal 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}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
551
backend/routes/forum.py
Normal 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
148
backend/routes/friends.py
Normal 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
113
backend/routes/knigge.py
Normal 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
171
backend/routes/lost.py
Normal 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
185
backend/routes/movies.py
Normal 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 (1800–1812). 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 # 1–5
|
||||
|
||||
|
||||
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
258
backend/routes/wiki.py
Normal 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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue