720 lines
28 KiB
Python
720 lines
28 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)
|
|
|
|
class ThreadPatch(BaseModel):
|
|
is_pinned: Optional[int] = None
|
|
is_locked: Optional[int] = None
|
|
|
|
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
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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
|
|
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, 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 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'))
|
|
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):
|
|
"""Cooldown, Stunden-Limit und Duplikat-Check für Forum-Posts."""
|
|
# 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:
|
|
from datetime import datetime as _dt
|
|
diff = (_dt.utcnow() - _dt.fromisoformat(last)).total_seconds()
|
|
if diff < 30:
|
|
raise HTTPException(429, "Bitte warte einen Moment bevor du erneut postest.")
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
# Stunden-Limit
|
|
if is_thread:
|
|
count = conn.execute(
|
|
"SELECT COUNT(*) FROM forum_threads WHERE user_id=? AND created_at > datetime('now','-1 hour')",
|
|
(user_id,),
|
|
).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 > datetime('now','-1 hour')",
|
|
(user_id,),
|
|
).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:
|
|
_check_post_limits(user["id"], conn, data.text.strip(), user.get("created_at"), is_thread=True)
|
|
ct = safe_client_time(data.client_time)
|
|
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 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, 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.")
|
|
|
|
_check_post_limits(user["id"], conn, data.text.strip(), user.get("created_at"), is_thread=False)
|
|
|
|
ct = safe_client_time(data.client_time)
|
|
cur = conn.execute(
|
|
"INSERT INTO forum_posts (thread_id, user_id, text, created_at) VALUES (?, ?, ?, ?)",
|
|
(thread_id, user['id'], data.text.strip(), ct)
|
|
)
|
|
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 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
|
|
# ------------------------------------------------------------------
|
|
_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 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(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]
|