diff --git a/VERSION b/VERSION index 93bf02c..61a4199 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1302 \ No newline at end of file +1303 \ No newline at end of file diff --git a/backend/database.py b/backend/database.py index a26bd07..8fad182 100644 --- a/backend/database.py +++ b/backend/database.py @@ -522,6 +522,7 @@ def _migrate(conn_factory): # Forum Sprint 11: erweiterte Thread-Felder ("forum_threads", "foto_urls", "TEXT"), ("forum_threads", "is_pinned", "INTEGER NOT NULL DEFAULT 0"), + ("forum_threads", "pin_scope", "TEXT NOT NULL DEFAULT 'global'"), ("forum_threads", "is_locked", "INTEGER NOT NULL DEFAULT 0"), ("forum_threads", "is_deleted", "INTEGER NOT NULL DEFAULT 0"), ("forum_threads", "likes", "INTEGER NOT NULL DEFAULT 0"), diff --git a/backend/routes/forum.py b/backend/routes/forum.py index 58d03b4..f8943ac 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -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( diff --git a/backend/static/index.html b/backend/static/index.html index 0905b1c..5a9d95f 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -624,12 +624,12 @@ - - - - - - + + + + + + @@ -639,7 +639,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 7651572..4cbbfb9 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '1302'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1303'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator) window.APP_VERSION = APP_VERSION; diff --git a/backend/static/js/pages/forum.js b/backend/static/js/pages/forum.js index 003160d..c9664b6 100644 --- a/backend/static/js/pages/forum.js +++ b/backend/static/js/pages/forum.js @@ -82,7 +82,8 @@ function _fmtDate(iso) { // RENDER — Grundstruktur // ---------------------------------------------------------- function _render() { - const isMod = !!_appState.user?.is_moderator; + const _u = _appState.user; + const isMod = !!(_u && (_u.rolle === 'admin' || _u.rolle === 'moderator' || _u.is_moderator)); _container.innerHTML = `
@@ -438,7 +439,7 @@ function _fmtDate(iso) { const preview = t.text_preview ? UI.escape(t.text_preview.slice(0, 120)) + (t.text_preview.length >= 120 ? '…' : '') : ''; - const pinBadge = t.is_pinned ? `${UI.icon('push-pin')}` : ''; + const pinBadge = t.is_pinned ? `${UI.icon('push-pin')}` : ''; const lockBadge = t.is_locked ? `${UI.icon('lock')}` : ''; const fotoHtml = t.foto_preview ? /\.(mp4|mov|webm|m4v|avi)$/i.test(t.foto_preview) @@ -515,14 +516,25 @@ function _fmtDate(iso) { } const uid = _appState.user?.id; - const isMod = !!_appState.user?.is_moderator; + const _u = _appState.user; + const isMod = !!(_u && (_u.rolle === 'admin' || _u.rolle === 'moderator' || _u.is_moderator)); const isOwn = uid && uid === thread.user_id; + const pinControls = thread.is_pinned + ? ` + ${UI.icon('push-pin')} Angepinnt${thread.pin_scope === 'kategorie' ? ` (Thema „${UI.escape(thread.kategorie)}")` : ' (global)'} + + ` + : ` + `; + const modToolbar = (isMod) ? `
- + ${pinControls} @@ -677,14 +689,20 @@ function _fmtDate(iso) { }); // Moderator: pin/lock/delete - document.querySelector('.forum-mod-pin')?.addEventListener('click', async () => { + const _applyPin = async (payload) => { try { - await API.forum.patchThread(thread.id, { is_pinned: thread.is_pinned ? 0 : 1 }); + await API.forum.patchThread(thread.id, payload); UI.toast.success('Gespeichert.'); UI.modal.close(); _loadThreads(true); } catch (err) { UI.toast.error(err.message); } - }); + }; + document.querySelector('.forum-mod-pin-global')?.addEventListener('click', + () => _applyPin({ is_pinned: 1, pin_scope: 'global' })); + document.querySelector('.forum-mod-pin-cat')?.addEventListener('click', + () => _applyPin({ is_pinned: 1, pin_scope: 'kategorie' })); + document.querySelector('.forum-mod-unpin')?.addEventListener('click', + () => _applyPin({ is_pinned: 0 })); document.querySelector('.forum-mod-lock')?.addEventListener('click', async () => { try { diff --git a/backend/static/landing.html b/backend/static/landing.html index 0b4cf3a..f190627 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index 660af2e..30f9606 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1302'; +const VER = '1303'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten diff --git a/tests/test_forum_pinning.py b/tests/test_forum_pinning.py new file mode 100644 index 0000000..d5c45f5 --- /dev/null +++ b/tests/test_forum_pinning.py @@ -0,0 +1,102 @@ +"""Forum-Anpinnen: Berechtigung (Admin/Moderator) + Scope-Sortierung. + +Deckt zwei neue Verhalten ab: +1. Admins OHNE is_moderator-Flag dürfen anpinnen (Rolle zählt, nicht nur Flag). +2. pin_scope='global' hält oben in JEDER Ansicht, pin_scope='kategorie' nur in + der gefilterten Kategorie (nicht in der "Alle"-Liste). + +Hinweise zu den Fixtures/Route-Constraints: +- `admin`-Fixture setzt rolle='admin' direkt in der DB, OHNE is_moderator. +- forum.create_thread hat 30s-Cooldown + 5-Threads/Stunde — daher gestaffelte + client_time. Texte sind eindeutig (token_hex) gegen den Duplikat-Check. +- Kategorie 'tauschboerse' isoliert die Sortier-Tests von anderen Suiten; + Assertions vergleichen nur die RELATIVE Reihenfolge der eigenen Thread-IDs. +""" + +from __future__ import annotations + +import secrets + + +def _mk_thread(client, headers, kategorie, titel, client_time): + r = client.post( + "/api/forum/threads", + headers=headers, + json={ + "kategorie": kategorie, + "titel": titel, + "text": f"{titel}: Hallo zusammen, ein Testbeitrag {secrets.token_hex(8)}.", + "client_time": client_time, + }, + ) + assert r.status_code == 201, f"create_thread failed: {r.status_code} {r.text}" + return r.json() + + +class TestForumPinPermission: + def test_admin_without_moderator_flag_can_pin(self, client, admin): + """rolle='admin' ohne is_moderator darf anpinnen (Kernursache-Fix).""" + t = _mk_thread(client, admin["headers"], "tauschboerse", "Admin Pin", "2026-06-18T08:00:00") + r = client.patch( + f"/api/forum/threads/{t['id']}", + headers=admin["headers"], + json={"is_pinned": 1, "pin_scope": "global"}, + ) + assert r.status_code == 200, f"{r.status_code} {r.text}" + body = r.json() + assert body["is_pinned"] == 1 + assert body["pin_scope"] == "global" + + def test_normal_user_cannot_pin(self, client, user): + """Normaler User → 403, Anpinnen bleibt Admin/Mod vorbehalten.""" + t = _mk_thread(client, user["headers"], "tauschboerse", "User Pin", "2026-06-18T08:00:00") + r = client.patch( + f"/api/forum/threads/{t['id']}", + headers=user["headers"], + json={"is_pinned": 1, "pin_scope": "global"}, + ) + assert r.status_code == 403 + + def test_invalid_pin_scope_rejected(self, client, admin): + """Ungültiger pin_scope → 400.""" + t = _mk_thread(client, admin["headers"], "tauschboerse", "Bad Scope", "2026-06-18T08:00:00") + r = client.patch( + f"/api/forum/threads/{t['id']}", + headers=admin["headers"], + json={"is_pinned": 1, "pin_scope": "bogus"}, + ) + assert r.status_code == 400 + + +class TestForumPinScopeSorting: + def test_scope_controls_where_thread_floats(self, client, admin): + h = admin["headers"] + t1 = _mk_thread(client, h, "tauschboerse", "Erster", "2026-06-18T09:00:00") + t2 = _mk_thread(client, h, "tauschboerse", "Zweiter", "2026-06-18T09:20:00") + t3 = _mk_thread(client, h, "tauschboerse", "Dritter", "2026-06-18T09:40:00") + ids = {t1["id"], t2["id"], t3["id"]} + + def order(kategorie=None): + url = "/api/forum/threads?limit=200" + if kategorie: + url += f"&kategorie={kategorie}" + r = client.get(url, headers=h) + assert r.status_code == 200 + return [x["id"] for x in r.json() if x["id"] in ids] + + # Ohne Pin: neueste zuerst. + assert order() == [t3["id"], t2["id"], t1["id"]] + + # Ältesten Thread GLOBAL anpinnen → oben in "Alle" UND in der Kategorie. + r = client.patch(f"/api/forum/threads/{t1['id']}", headers=h, + json={"is_pinned": 1, "pin_scope": "global"}) + assert r.status_code == 200 + assert order()[0] == t1["id"] + assert order("tauschboerse")[0] == t1["id"] + + # Auf Themen-Pin umstellen → NUR in der Kategorie oben, in "Alle" wieder nach Datum. + r = client.patch(f"/api/forum/threads/{t1['id']}", headers=h, + json={"pin_scope": "kategorie"}) + assert r.status_code == 200 + assert order() == [t3["id"], t2["id"], t1["id"]] # "Alle": Themen-Pin zählt nicht + assert order("tauschboerse")[0] == t1["id"] # Kategorie: oben