Forum: Anpinnen pro Thema/global + Admin-Berechtigung (v1303)

- Anpinnen-Scope: pin_scope ('global' | 'kategorie'). Global haelt oben in
  jeder Ansicht; Themen-Pin nur in der gefilterten Kategorie (nicht in 'Alle').
- Bugfix Berechtigung: Forum pruefte nur is_moderator -> Admins ohne das Flag
  wurden ausgesperrt. Neuer Helper _can_moderate() = rolle in (admin,moderator)
  ODER is_moderator, an allen 7 Forum-Checks + beiden Frontend-isMod-Gates.
- Thread-Detail-Toolbar (nur Admin/Mod): 'Global anpinnen' / 'Im Thema anpinnen'
  / 'Loesen' + Status- und Badge-Anzeige nach Scope.
- DB-Migration forum_threads.pin_scope (idempotent, Default 'global').
- Tests: tests/test_forum_pinning.py (Berechtigung + Scope-Sortierung).
This commit is contained in:
rene 2026-06-18 20:36:06 +02:00
parent 901df5468c
commit ac0814e687
9 changed files with 175 additions and 36 deletions

View file

@ -42,6 +42,7 @@ class PostCreate(BaseModel):
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)
@ -71,6 +72,15 @@ 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
# ------------------------------------------------------------------
@ -126,12 +136,13 @@ async def list_threads(
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.is_locked, t.foto_urls,
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
@ -139,13 +150,18 @@ async def list_threads(
WHERE t.is_deleted = 0
"""
params = []
if kategorie and kategorie != 'alle':
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}%'])
q += " ORDER BY t.is_pinned DESC, t.created_at DESC LIMIT ? OFFSET ?"
# 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()
@ -323,7 +339,7 @@ async def delete_thread(thread_id: int, user=Depends(get_current_user)):
).fetchone()
if not thread:
raise HTTPException(404, "Thread nicht gefunden.")
if thread['user_id'] != user['id'] and not user.get('is_moderator'):
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,)
@ -335,7 +351,7 @@ async def delete_thread(thread_id: int, user=Depends(get_current_user)):
# ------------------------------------------------------------------
@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'):
if not _can_moderate(user):
raise HTTPException(403, "Nur Moderatoren können Threads bearbeiten.")
with db() as conn:
thread = conn.execute(
@ -345,6 +361,8 @@ async def patch_thread(thread_id: int, data: ThreadPatch, user=Depends(get_curre
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(
@ -476,7 +494,7 @@ async def delete_post(post_id: int, user=Depends(get_current_user)):
).fetchone()
if not post:
raise HTTPException(404, "Beitrag nicht gefunden.")
if post['user_id'] != user['id'] and not user.get('is_moderator'):
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,)
@ -504,7 +522,7 @@ async def upload_thread_foto(
).fetchone()
if not thread:
raise HTTPException(404, "Thread nicht gefunden.")
if thread['user_id'] != user['id'] and not user.get('is_moderator'):
if thread['user_id'] != user['id'] and not _can_moderate(user):
raise HTTPException(403, "Keine Berechtigung.")
existing = _parse_foto_urls(thread['foto_urls'])
@ -537,7 +555,7 @@ async def upload_post_foto(
).fetchone()
if not post:
raise HTTPException(404, "Beitrag nicht gefunden.")
if post['user_id'] != user['id'] and not user.get('is_moderator'):
if post['user_id'] != user['id'] and not _can_moderate(user):
raise HTTPException(403, "Keine Berechtigung.")
existing = _parse_foto_urls(post['foto_urls'])
@ -642,7 +660,7 @@ async def report_content(data: ReportBody, user=Depends(get_current_user)):
# ------------------------------------------------------------------
@router.get("/reports")
async def list_reports(user=Depends(get_current_user)):
if not user.get('is_moderator'):
if not _can_moderate(user):
raise HTTPException(403, "Nur Moderatoren.")
with db() as conn:
rows = conn.execute(
@ -660,7 +678,7 @@ async def list_reports(user=Depends(get_current_user)):
# ------------------------------------------------------------------
@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'):
if not _can_moderate(user):
raise HTTPException(403, "Nur Moderatoren.")
with db() as conn:
conn.execute(