banyaro/backend/routes/forum.py
rene 6ea3f50b05 Forum: idempotente Antworten gegen Doppelpost/Cooldown-Fehler bei Funkloch (v1306)
Praxisfall: Antwort wird serverseitig erstellt, aber die HTTP-Antwort geht
unterwegs verloren (schlechtes Netz). UI zeigt Fehler statt Erfolg, Text bleibt
stehen -> Nutzer tippt erneut -> 2. Versuch laeuft in den 30s-Cooldown (429),
der bereits gepostete Beitrag bleibt unsichtbar.

- forum_posts.client_uuid (Migration). Reply mit stabiler client_uuid:
  Retry liefert den BEREITS erstellten Post zurueck (kein Cooldown/Doppelpost).
- Frontend: UUID bleibt ueber Retries stabil, Reset erst nach Erfolg; Foto-
  Doppel-Upload bei Retry verhindert.
- Anti-Spam-Cooldown bleibt fuer echte neue Posts aktiv.
- Tests: tests/test_forum_idempotency.py (Retry=selber Post, Cooldown greift,
  ohne UUID rueckwaertskompatibel).
2026-06-19 10:29:42 +02:00

771 lines
31 KiB
Python

"""BAN YARO — Forum (Sprint 11)"""
import os, uuid, json, logging
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel, Field
from typing import Optional
from database import db
from auth import get_current_user, get_current_user_optional
from timeutils import safe_client_time
from ratelimit import is_duplicate_post, record_post
from content_filter import check_forum_content
from routes.push import send_push_to_user
from media_utils import convert_media, extract_video_thumb, generate_preview, preview_url_from
logger = logging.getLogger(__name__)
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
FORUM_DIR = os.path.join(MEDIA_DIR, "forum")
KATEGORIEN = ['allgemein', 'rasse', 'region', 'gesundheit', 'erziehung',
'spaziergang', 'ausflug', 'training', 'ernaehrung', 'probleme', 'tauschboerse']
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class ThreadCreate(BaseModel):
kategorie: str = Field('allgemein', max_length=100)
titel: str = Field(..., min_length=3, max_length=200)
text: str = Field(..., min_length=1, max_length=10000)
thread_lat: Optional[float] = None
thread_lon: Optional[float] = None
thread_ort: Optional[str] = Field(None, max_length=300)
client_time: Optional[str] = Field(None, max_length=64)
class PostCreate(BaseModel):
text: str = Field(..., min_length=1, max_length=10000)
client_time: Optional[str] = Field(None, max_length=64)
client_uuid: Optional[str] = Field(None, max_length=64) # Idempotenz-Schlüssel gegen Doppelposts
class ThreadPatch(BaseModel):
is_pinned: Optional[int] = None
is_locked: Optional[int] = None
pin_scope: Optional[str] = None # 'global' (überall oben) | 'kategorie' (nur im Thema oben)
class ThreadUpdate(BaseModel):
titel: Optional[str] = Field(None, max_length=200)
text: Optional[str] = Field(None, max_length=10000)
thread_lat: Optional[float] = None
thread_lon: Optional[float] = None
thread_ort: Optional[str] = Field(None, max_length=300)
class PostUpdate(BaseModel):
text: str = Field(..., min_length=1, max_length=10000)
class LikeBody(BaseModel):
target_type: str = Field(..., max_length=20) # 'thread' | 'post'
target_id: int
class ReportBody(BaseModel):
target_type: str = Field(..., max_length=20)
target_id: int
grund: str = Field(..., min_length=3, max_length=1000)
class LocationBody(BaseModel):
lat: Optional[float] = None
lon: Optional[float] = None
show: bool = False
class ResolveReport(BaseModel):
resolved: int = 1
def _can_moderate(user) -> bool:
"""Admin ODER Moderator dürfen moderieren (pin/lock/löschen).
Wichtig: Admins haben nicht zwingend das is_moderator-Flag gesetzt —
daher zusätzlich die Rolle prüfen (analog auth.require_moderator)."""
if not user:
return False
return user.get('rolle') in ('admin', 'moderator') or bool(user.get('is_moderator'))
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
_FORUM_ALLOWED_EXT = {".jpg",".jpeg",".png",".gif",".webp",".heic",".heif",
".mp4",".mov",".webm",".m4v",".pdf",".avi"}
def _save_upload(file: UploadFile, data: bytes) -> str:
os.makedirs(FORUM_DIR, exist_ok=True)
ext = os.path.splitext(file.filename or "")[1].lower() or ".jpg"
if ext not in _FORUM_ALLOWED_EXT:
raise HTTPException(415, "Dateityp nicht erlaubt.")
data, ext = convert_media(data, file.filename or "")
filename = f"{uuid.uuid4().hex}{ext}"
path = os.path.join(FORUM_DIR, filename)
with open(path, "wb") as f:
f.write(data)
if ext in {".mp4", ".webm"}:
extract_video_thumb(path)
else:
preview_bytes = generate_preview(data, ext)
if preview_bytes:
with open(os.path.splitext(path)[0] + "_preview.webp", "wb") as f:
f.write(preview_bytes)
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
has_cat = bool(kategorie and kategorie != 'alle')
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.pin_scope, t.is_locked, t.foto_urls,
t.created_at, t.user_id,
u.name AS autor_name, u.founder_number AS autor_founder_number
FROM forum_threads t
LEFT JOIN users u ON u.id = t.user_id
WHERE t.is_deleted = 0
"""
params = []
if has_cat:
q += " AND t.kategorie = ?"
params.append(kategorie)
if search:
q += " AND (t.titel LIKE ? OR t.text LIKE ?)"
params.extend([f'%{search}%', f'%{search}%'])
# Kategorie-Ansicht: globale UND Themen-Pins steigen nach oben.
# "Alle"-Ansicht: nur globale Pins oben — Themen-Pins bleiben in ihrem Thema.
if has_cat:
q += " ORDER BY t.is_pinned DESC, t.created_at DESC LIMIT ? OFFSET ?"
else:
q += " ORDER BY (t.is_pinned = 1 AND t.pin_scope = 'global') 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'))
first = foto_list[0] if foto_list else None
t['foto_preview'] = first
t['foto_preview_url'] = preview_url_from(first)
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
# ------------------------------------------------------------------
def _check_post_limits(user_id: int, conn, text: str, user_created_at: str | None = None,
is_thread: bool = False, now_client: str | None = None):
"""Cooldown, Stunden-Limit und Duplikat-Check für Forum-Posts.
WICHTIG: created_at wird als Client-Lokalzeit gespeichert (safe_client_time).
Alle Zeit-Checks müssen daher gegen die gleiche Zeitbasis rechnen — sonst
sorgt der UTC/Lokalzeit-Versatz (z.B. CEST = UTC+2) dafür, dass der Cooldown
dauerhaft greift (diff wird negativ → immer < 30). Referenz ist die
Client-Zeit dieses Requests (now_client), Fallback UTC.
"""
from datetime import datetime as _dt, timedelta as _td
try:
now_dt = _dt.fromisoformat(now_client) if now_client else _dt.utcnow()
except (ValueError, TypeError):
now_dt = _dt.utcnow()
# 30-Sekunden-Cooldown zwischen beliebigen Posts
last = conn.execute(
"""SELECT MAX(created_at) AS last FROM (
SELECT created_at FROM forum_threads WHERE user_id=?
UNION ALL
SELECT created_at FROM forum_posts WHERE user_id=?
)""",
(user_id, user_id),
).fetchone()["last"]
if last:
try:
diff = (now_dt - _dt.fromisoformat(last)).total_seconds()
if 0 <= diff < 30:
raise HTTPException(429, "Bitte warte einen Moment bevor du erneut postest.")
except (ValueError, TypeError):
pass
# Stunden-Limit (gleiche Zeitbasis wie created_at)
hour_ago = (now_dt - _td(hours=1)).strftime("%Y-%m-%d %H:%M:%S")
if is_thread:
count = conn.execute(
"SELECT COUNT(*) FROM forum_threads WHERE user_id=? AND created_at > ?",
(user_id, hour_ago),
).fetchone()[0]
if count >= 5:
raise HTTPException(429, "Du hast in dieser Stunde bereits 5 Threads erstellt. Bitte warte etwas.")
else:
count = conn.execute(
"SELECT COUNT(*) FROM forum_posts WHERE user_id=? AND created_at > ?",
(user_id, hour_ago),
).fetchone()[0]
if count >= 20:
raise HTTPException(429, "Du hast in dieser Stunde bereits 20 Antworten geschrieben. Bitte warte etwas.")
# Duplikat-Check
if is_duplicate_post(user_id, text):
raise HTTPException(400, "Dieser Beitrag wurde bereits kürzlich gepostet.")
# Content-Filter
check_forum_content(text, user_created_at)
@router.post("/threads", status_code=201)
async def create_thread(data: ThreadCreate, user=Depends(get_current_user)):
if not user.get("email_verified"):
raise HTTPException(403, "Bitte bestätige zuerst deine E-Mail-Adresse um im Forum zu schreiben.")
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:
ct = safe_client_time(data.client_time)
_check_post_limits(user["id"], conn, data.text.strip(), user.get("created_at"), is_thread=True, now_client=ct)
cur = conn.execute(
"""INSERT INTO forum_threads (user_id, kategorie, titel, text, thread_lat, thread_lon, thread_ort, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(user['id'], data.kategorie, data.titel.strip(), data.text.strip(),
data.thread_lat, data.thread_lon, data.thread_ort, ct)
)
row = conn.execute(
"""SELECT t.*, u.name AS autor_name, u.founder_number AS autor_founder_number
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
record_post(user["id"], data.text.strip())
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, u.founder_number AS autor_founder_number
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, u.founder_number AS autor_founder_number
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 _can_moderate(user):
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 _can_moderate(user):
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 'pin_scope' in updates and updates['pin_scope'] not in ('global', 'kategorie'):
raise HTTPException(400, "Ungültiger pin_scope (erlaubt: 'global', 'kategorie').")
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, u.founder_number AS autor_founder_number
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 user.get("email_verified"):
raise HTTPException(403, "Bitte bestätige zuerst deine E-Mail-Adresse um im Forum zu schreiben.")
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.")
# Idempotenz: Ein Retry mit derselben client_uuid (z.B. wenn die Antwort
# des 1. Versuchs im Funkloch verloren ging) liefert den BEREITS erstellten
# Post zurück — statt Cooldown-/Duplikat-Fehler oder Doppelpost. Der Client
# behält die UUID über Retries hinweg und setzt sie erst nach Erfolg zurück.
if data.client_uuid:
existing = conn.execute(
"""SELECT p.*, u.name AS autor_name, u.founder_number AS autor_founder_number
FROM forum_posts p
LEFT JOIN users u ON u.id = p.user_id
WHERE p.user_id=? AND p.client_uuid=? AND p.thread_id=?""",
(user["id"], data.client_uuid, thread_id)
).fetchone()
if existing:
ed = dict(existing)
ed['foto_urls'] = _parse_foto_urls(ed.get('foto_urls'))
ed['user_liked'] = _user_liked(conn, user["id"], 'post', ed['id'])
return ed
ct = safe_client_time(data.client_time)
_check_post_limits(user["id"], conn, data.text.strip(), user.get("created_at"), is_thread=False, now_client=ct)
cur = conn.execute(
"INSERT INTO forum_posts (thread_id, user_id, text, created_at, client_uuid) VALUES (?, ?, ?, ?, ?)",
(thread_id, user['id'], data.text.strip(), ct, data.client_uuid)
)
conn.execute(
"UPDATE forum_threads SET antworten = antworten + 1 WHERE id = ?",
(thread_id,)
)
row = conn.execute(
"""SELECT p.*, u.name AS autor_name, u.founder_number AS autor_founder_number
FROM forum_posts p
LEFT JOIN users u ON u.id = p.user_id
WHERE p.id = ?""",
(cur.lastrowid,)
).fetchone()
# Thread-Owner ermitteln für Push-Notification
owner_row = conn.execute(
"SELECT user_id FROM forum_threads WHERE id = ?", (thread_id,)
).fetchone()
owner_id = owner_row['user_id'] if owner_row else None
pd = dict(row)
pd['foto_urls'] = []
pd['user_liked'] = False
record_post(user["id"], data.text.strip())
# Push-Notification an Thread-Owner (nicht an sich selbst)
if owner_id and owner_id != user['id']:
try:
commenter_name = pd.get('autor_name') or 'Jemand'
send_push_to_user(owner_id, {
"type": "forum_reply",
"title": "Neue Antwort auf deinen Beitrag",
"body": f"{commenter_name} hat auf deinen Beitrag geantwortet",
"tag": f"forum-{thread_id}",
"data": {"page": "forum", "id": thread_id},
})
except Exception:
logger.exception("Push-Notification für Forum-Reply fehlgeschlagen (nicht kritisch)")
return pd
# ------------------------------------------------------------------
# PATCH /api/forum/threads/{id}/content — Besitzer: titel/text
# ------------------------------------------------------------------
@router.patch("/threads/{thread_id}/content")
async def update_thread_content(thread_id: int, data: ThreadUpdate, user=Depends(get_current_user)):
with db() as conn:
t = conn.execute("SELECT user_id FROM forum_threads WHERE id=?", (thread_id,)).fetchone()
if not t: raise HTTPException(404, "Thread nicht gefunden.")
if t["user_id"] != user["id"]: raise HTTPException(403, "Nicht dein Beitrag.")
updates, vals = [], []
if data.titel is not None and data.titel.strip():
updates.append("titel=?"); vals.append(data.titel.strip())
if data.text is not None:
updates.append("text=?"); vals.append(data.text.strip())
# Location immer überschreiben (auch mit None zum Löschen)
updates += ["thread_lat=?", "thread_lon=?", "thread_ort=?"]
vals += [data.thread_lat, data.thread_lon,
data.thread_ort.strip() if data.thread_ort else None]
conn.execute(f"UPDATE forum_threads SET {','.join(updates)} WHERE id=?", (*vals, thread_id))
return {"ok": True}
# ------------------------------------------------------------------
# PATCH /api/forum/posts/{id} — Besitzer: text
# ------------------------------------------------------------------
@router.patch("/posts/{post_id}")
async def update_post(post_id: int, data: PostUpdate, user=Depends(get_current_user)):
with db() as conn:
p = conn.execute("SELECT user_id FROM forum_posts WHERE id=?", (post_id,)).fetchone()
if not p: raise HTTPException(404, "Beitrag nicht gefunden.")
if p["user_id"] != user["id"]: raise HTTPException(403, "Nicht dein Beitrag.")
conn.execute("UPDATE forum_posts SET text=? WHERE id=?", (data.text.strip(), post_id))
return {"ok": True}
# ------------------------------------------------------------------
# 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 _can_moderate(user):
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 _can_moderate(user):
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 _can_moderate(user):
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
# ------------------------------------------------------------------
_LIKE_TABLE = {'thread': 'forum_threads', 'post': 'forum_posts'}
@router.post("/like")
async def toggle_like(data: LikeBody, user=Depends(get_current_user)):
if data.target_type not in _LIKE_TABLE:
raise HTTPException(400, "Ungültiger Typ.")
table = _LIKE_TABLE[data.target_type]
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}
# ------------------------------------------------------------------
# GET /api/forum/likes/{target_type}/{target_id} — Wer hat geliked?
# ------------------------------------------------------------------
@router.get("/likes/{target_type}/{target_id}")
async def list_likers(target_type: str, target_id: int):
if target_type not in _LIKE_TABLE:
raise HTTPException(400, "Ungültiger Typ.")
with db() as conn:
rows = conn.execute(
"""SELECT u.name AS name, u.founder_number AS founder_number
FROM forum_likes fl
JOIN users u ON u.id = fl.user_id
WHERE fl.target_type = ? AND fl.target_id = ?
ORDER BY fl.id DESC""",
(target_type, target_id)
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# 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 _can_moderate(user):
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 _can_moderate(user):
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(user=Depends(get_current_user)):
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 [{'vorname': r['vorname'] or '?', 'lat': round(r['lat'], 3), 'lon': round(r['lon'], 3)}
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:
with db() as conn:
conn.execute(
"""UPDATE users SET forum_lat=?, forum_lon=?, forum_show_location=1
WHERE id=?""",
(round(data.lat, 4), round(data.lon, 4), user['id'])
)
return {"ok": True, "lat": data.lat, "lon": data.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]