- 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).
102 lines
4.3 KiB
Python
102 lines
4.3 KiB
Python
"""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
|