feat(P-13): Club-Admin Bearbeitung + Archiv-Trennung in Inbox
Some checks failed
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Failing after 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 10s
Test Suite / playwright-tests (push) Successful in 1m3s

Berechtigungen:
- Club-Admins koennen Meldungen zu Vereinsmedien bearbeiten (PATCH + GET-Detail)
- Club-Admins koennen Legal Hold auf Vereinsmedien (visibility != 'official') setzen
- Neue Helfer: _assert_can_manage_report, _assert_can_set_legal_hold_from_report
- set_legal_hold_from_report: Superadmin-Only aufgehoben fuer Vereinsebene

Inbox UI:
- Offene Meldungen (submitted/under_review) im Hauptbereich
- Abgeschlossene Meldungen im kollabierbaren Archiv (standardmaessig zugeklappt)
- Legal-Hold-Button sichtbar fuer Club-Admins bei nicht-offiziellen Medien
- isClubAdmin + isPlatformAdmin aus OrgInboxContext verfuegbar

version: 0.8.93

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-05-11 22:15:29 +02:00
parent 9af28faa35
commit 5cf61289ec
5 changed files with 191 additions and 98 deletions

View File

@ -152,9 +152,60 @@ class ContentReportLegalHoldBody(BaseModel):
# ─── Hilfsfunktionen ────────────────────────────────────────────────────────
def _assert_platform_admin(role: Optional[str]) -> None:
if not is_platform_admin(role):
raise HTTPException(status_code=403, detail="Nur Plattform-Admins koennen Meldungen bearbeiten")
def _assert_can_manage_report(cur, role: Optional[str], pid: int, report: dict) -> None:
"""Platform-Admins: jede Meldung. Club-Admin: nur Meldungen zu Medien ihres Vereins."""
if is_platform_admin(role):
return
if report.get("target_type") != "media_asset":
raise HTTPException(status_code=403, detail="Nur Plattform-Admins können Meldungen zu Übungen bearbeiten")
cur.execute("SELECT club_id FROM media_assets WHERE id = %s", (int(report["target_id"]),))
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
club_id = r2d(row).get("club_id")
if not club_id:
raise HTTPException(status_code=403, detail="Kein Zugriff Medium ohne Vereinszugehörigkeit")
cur.execute(
"""
SELECT 1 FROM club_members cm
INNER JOIN club_member_roles r ON r.club_member_id = cm.id
WHERE cm.profile_id = %s AND cm.club_id = %s
AND cm.status = 'active' AND r.role_code = 'club_admin'
""",
(pid, club_id),
)
if cur.fetchone() is None:
raise HTTPException(status_code=403, detail="Kein Zugriff auf diese Meldung")
def _assert_can_set_legal_hold_from_report(cur, role: Optional[str], pid: int, asset_id: int) -> None:
"""Superadmin: immer. Club-Admin: nur für Vereinsmedien mit visibility != 'official'."""
if is_superadmin(role):
return
cur.execute("SELECT club_id, visibility FROM media_assets WHERE id = %s", (asset_id,))
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
asset = r2d(row)
if asset.get("visibility") == "official":
raise HTTPException(
status_code=403,
detail="Legal Hold auf offiziellen Medien erfordert Superadmin-Rechte",
)
club_id = asset.get("club_id")
if not club_id:
raise HTTPException(status_code=403, detail="Legal Hold erfordert Superadmin-Rechte für Medien ohne Vereinszugehörigkeit")
cur.execute(
"""
SELECT 1 FROM club_members cm
INNER JOIN club_member_roles r ON r.club_member_id = cm.id
WHERE cm.profile_id = %s AND cm.club_id = %s
AND cm.status = 'active' AND r.role_code = 'club_admin'
""",
(pid, club_id),
)
if cur.fetchone() is None:
raise HTTPException(status_code=403, detail="Kein Zugriff Superadmin oder Vereinsadmin des Vereins erforderlich")
def _is_media_asset_visible_anonymous(cur, asset_id: int) -> bool:
@ -651,8 +702,9 @@ def get_content_report(
report_id: int,
tenant: TenantContext = Depends(get_tenant_context),
):
"""Detail-Ansicht einer Meldung fuer Plattform-Admins."""
_assert_platform_admin(tenant.global_role)
"""Detail-Ansicht einer Meldung fuer Plattform-Admins und zustaendige Club-Admins."""
pid = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
@ -683,7 +735,9 @@ def get_content_report(
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Meldung nicht gefunden")
return _report_row_to_dict(row)
report_dict = _report_row_to_dict(row)
_assert_can_manage_report(cur, role, pid, report_dict)
return report_dict
@router.patch("/content-reports/{report_id}")
@ -695,9 +749,10 @@ def patch_content_report(
"""
Status und Bearbeitungsnotiz einer Meldung aktualisieren.
Abschluss ohne Massnahme (resolved_no_action, rejected_invalid) erfordert resolution_note.
Plattform-Admins: jede Meldung. Club-Admins: nur Meldungen zu Medien ihres Vereins.
"""
_assert_platform_admin(tenant.global_role)
pid = tenant.profile_id
role = tenant.global_role
if body.status in ("resolved_no_action", "rejected_invalid") and not (body.resolution_note or "").strip():
raise HTTPException(
@ -723,6 +778,7 @@ def patch_content_report(
if not row:
raise HTTPException(status_code=404, detail="Meldung nicht gefunden")
old_report = r2d(row)
_assert_can_manage_report(cur, role, pid, old_report)
old_status = old_report["status"]
old_note = (old_report.get("resolution_note") or "").strip()
@ -802,13 +858,14 @@ def set_legal_hold_from_report(
):
"""
Legal Hold (P-11) aus einer Meldung heraus setzen.
Nur Superadmin. Nur fuer Meldungen mit target_type='media_asset'.
Superadmin: immer. Club-Admin: nur fuer Vereinsmedien (visibility != 'official').
Nur fuer Meldungen mit target_type='media_asset'.
Der Reason-Code wird automatisch aus dem report_reason der Meldung abgeleitet.
Nach dem Setzen wird der Report-Status auf 'resolved_legal_hold' gesetzt.
"""
assert_superadmin_for_legal_hold(tenant.global_role)
pid = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
@ -828,6 +885,7 @@ def set_legal_hold_from_report(
)
asset_id = int(report["target_id"])
_assert_can_set_legal_hold_from_report(cur, role, pid, asset_id)
reason_code = _REASON_TO_HOLD_CODE.get(report["report_reason"], "other")
# P-11-Service aufrufen

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.92"
APP_VERSION = "0.8.93"
BUILD_DATE = "2026-05-11"
DB_SCHEMA_VERSION = "20260511053"
@ -30,10 +30,20 @@ MODULE_VERSIONS = {
"membership": "1.0.0",
"catalogs": "1.5.0", # Updated: Trainer Contexts API (Migration 012)
"maturity_models": "1.4.0", # matrix_stack_bundle: vollständiger Katalog+Modelle+Bindings Export/Import
"content_reports": "1.4.0", # P-13: Workflow-Reset (wieder öffnen), Kommentar-Audit-Log, PATCH-Verbesserungen
"content_reports": "1.5.0", # P-13: Club-Admin Bearbeitung + Legal Hold (Vereinsebene), Archiv-Trennung
}
CHANGELOG = [
{
"version": "0.8.93",
"date": "2026-05-11",
"changes": [
"Fix P-13: Club-Admins können Inhaltsmeldungen zu Vereinsmedien bearbeiten (PATCH, GET-Detail).",
"Fix P-13: Club-Admins können Legal Hold auf Vereinsmedien (nicht 'official') aus Meldung heraus setzen.",
"Fix P-13: Abgeschlossene Meldungen in der Inbox in kollabierbare Archiv-Sektion verschoben.",
"Fix P-13: isClubAdmin + isPlatformAdmin im OrgInboxContext exponiert.",
],
},
{
"version": "0.8.92",
"date": "2026-05-11",

View File

@ -115,8 +115,10 @@ export function OrgInboxProvider({ user, children }) {
canAccessOrgInbox: canAccess,
canAccessContentReports: canAccessReports,
isSuperadmin: user?.role === 'superadmin',
isPlatformAdmin: user?.role === 'admin' || user?.role === 'superadmin',
isClubAdmin: activeClubMemberships(user?.clubs || []).some((c) => (c.roles || []).includes('club_admin')),
}),
[items, contentReports, contentReportsError, refresh, canAccess, canAccessReports, user?.role]
[items, contentReports, contentReportsError, refresh, canAccess, canAccessReports, user?.role, user?.clubs]
)
return <OrgInboxContext.Provider value={value}>{children}</OrgInboxContext.Provider>

View File

@ -107,7 +107,7 @@ function WorkflowBar({ status }) {
)
}
function ReportDetailModal({ report, onClose, onRefresh, isSuperadmin }) {
function ReportDetailModal({ report, onClose, onRefresh, isSuperadmin, isClubAdmin }) {
const [resolutionNote, setResolutionNote] = useState(report.resolution_note || '')
const [legalHoldNote, setLegalHoldNote] = useState('')
const [saving, setSaving] = useState(false)
@ -279,13 +279,13 @@ function ReportDetailModal({ report, onClose, onRefresh, isSuperadmin }) {
Meldung abweisen
</button>
</div>
{isSuperadmin && !showLegalHoldForm && (
{(isSuperadmin || (isClubAdmin && report.target_visibility !== 'official')) && !showLegalHoldForm && (
<button
type="button" className="btn"
style={{ background: 'rgba(216,90,48,0.12)', color: 'var(--danger)', border: '1px solid var(--danger)' }}
onClick={() => setShowLegalHoldForm(true)}
>
Legal Hold setzen (Superadmin)
Legal Hold setzen {isSuperadmin ? '(Superadmin)' : '(Vereinsadmin)'}
</button>
)}
{isSuperadmin && showLegalHoldForm && (
@ -325,6 +325,8 @@ export default function InboxPage() {
canAccessOrgInbox,
canAccessContentReports,
isSuperadmin,
isPlatformAdmin,
isClubAdmin,
refreshOrgInbox,
inboxJoinRequests,
contentReports,
@ -334,6 +336,7 @@ export default function InboxPage() {
const [loading, setLoading] = useState(true)
const [acceptModal, setAcceptModal] = useState(null)
const [reportModal, setReportModal] = useState(null)
const [showArchive, setShowArchive] = useState(false)
const load = useCallback(async () => {
if (!canAccessOrgInbox && !canAccessContentReports) {
@ -471,89 +474,108 @@ export default function InboxPage() {
</section>
)}
{/* Abschnitt 2: Inhaltsmeldungen (nur Plattform-Admins) */}
{canAccessContentReports && (
<section>
<h2 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text2)' }}>
Inhaltsmeldungen
{contentReportCount > 0 && (
<span
style={{
background: 'var(--danger)',
color: '#fff',
borderRadius: '12px',
padding: '1px 8px',
fontSize: '0.75rem',
marginLeft: '0.5rem',
}}
>
{contentReportCount} neu
</span>
)}
</h2>
{/* Abschnitt 2: Inhaltsmeldungen */}
{canAccessContentReports && (() => {
const openReports = contentReports.filter((r) => r.status === 'submitted' || r.status === 'under_review')
const archivedReports = contentReports.filter((r) => r.status !== 'submitted' && r.status !== 'under_review')
{contentReportsError ? (
<div className="card" style={{ padding: '1.25rem', borderLeft: '3px solid var(--danger)' }}>
<p style={{ margin: 0, color: 'var(--danger)', fontWeight: 600, fontSize: '0.88rem' }}>
Fehler beim Laden: {contentReportsError}
</p>
<button type="button" className="btn btn-secondary" style={{ marginTop: '0.5rem', fontSize: '0.82rem' }} onClick={load}>
Erneut versuchen
</button>
</div>
) : contentReports.length === 0 ? (
<div className="card" style={{ padding: '1.25rem' }}>
<p style={{ margin: 0 }} className="muted">Keine Inhaltsmeldungen.</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{contentReports.map((rep) => (
<div
key={rep.id}
className="card"
style={{
padding: '1rem 1.25rem',
cursor: 'pointer',
borderLeft: rep.priority === 'high' ? '3px solid var(--danger)' : '3px solid transparent',
}}
onClick={() => setReportModal(rep)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.5rem' }}>
<div>
<span style={{ fontWeight: 600 }}>
Meldung #{rep.id}
<PriorityBadge priority={rep.priority} />
</span>
<span className="muted" style={{ marginLeft: '0.75rem', fontSize: '0.85rem' }}>
{rep.target_type === 'media_asset' ? 'Medium' : 'Übung'} #{rep.target_id}
{rep.target_filename || rep.target_exercise_name ? ` ${rep.target_filename || rep.target_exercise_name}` : ''}
</span>
</div>
<span
style={{
fontSize: '0.78rem',
fontWeight: 500,
color: STATUS_COLORS[rep.status],
whiteSpace: 'nowrap',
}}
>
{STATUS_LABELS[rep.status] || rep.status}
</span>
</div>
<div className="muted" style={{ fontSize: '0.85rem', marginTop: '0.3rem' }}>
{REASON_LABELS[rep.report_reason] || rep.report_reason}
{' · '}
{rep.reporter_name}
{rep.reporter_profile_id ? ` (Profil #${rep.reporter_profile_id})` : ' (anonym)'}
{' · '}
{formatWhen(rep.submitted_at || rep.created_at)}
</div>
function ReportCard({ rep }) {
return (
<div
key={rep.id}
className="card"
style={{
padding: '1rem 1.25rem',
cursor: 'pointer',
borderLeft: rep.priority === 'high' ? '3px solid var(--danger)' : '3px solid transparent',
opacity: rep.status !== 'submitted' && rep.status !== 'under_review' ? 0.75 : 1,
}}
onClick={() => setReportModal(rep)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.5rem' }}>
<div>
<span style={{ fontWeight: 600 }}>
Meldung #{rep.id}
<PriorityBadge priority={rep.priority} />
</span>
<span className="muted" style={{ marginLeft: '0.75rem', fontSize: '0.85rem' }}>
{rep.target_type === 'media_asset' ? 'Medium' : 'Übung'} #{rep.target_id}
{rep.target_filename || rep.target_exercise_name ? ` ${rep.target_filename || rep.target_exercise_name}` : ''}
</span>
</div>
))}
<span style={{ fontSize: '0.78rem', fontWeight: 500, color: STATUS_COLORS[rep.status], whiteSpace: 'nowrap' }}>
{STATUS_LABELS[rep.status] || rep.status}
</span>
</div>
<div className="muted" style={{ fontSize: '0.85rem', marginTop: '0.3rem' }}>
{REASON_LABELS[rep.report_reason] || rep.report_reason}
{' · '}
{rep.reporter_name}
{rep.reporter_profile_id ? ` (Profil #${rep.reporter_profile_id})` : ' (anonym)'}
{' · '}
{formatWhen(rep.submitted_at || rep.created_at)}
</div>
</div>
)}
</section>
)}
)
}
return (
<section>
<h2 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text2)' }}>
Inhaltsmeldungen
{contentReportCount > 0 && (
<span style={{ background: 'var(--danger)', color: '#fff', borderRadius: '12px', padding: '1px 8px', fontSize: '0.75rem', marginLeft: '0.5rem' }}>
{contentReportCount} neu
</span>
)}
</h2>
{contentReportsError ? (
<div className="card" style={{ padding: '1.25rem', borderLeft: '3px solid var(--danger)' }}>
<p style={{ margin: 0, color: 'var(--danger)', fontWeight: 600, fontSize: '0.88rem' }}>
Fehler beim Laden: {contentReportsError}
</p>
<button type="button" className="btn btn-secondary" style={{ marginTop: '0.5rem', fontSize: '0.82rem' }} onClick={load}>
Erneut versuchen
</button>
</div>
) : (
<>
{openReports.length === 0 ? (
<div className="card" style={{ padding: '1.25rem', marginBottom: '0.75rem' }}>
<p style={{ margin: 0 }} className="muted">Keine offenen Inhaltsmeldungen.</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', marginBottom: '0.75rem' }}>
{openReports.map((rep) => <ReportCard key={rep.id} rep={rep} />)}
</div>
)}
{archivedReports.length > 0 && (
<div>
<button
type="button"
style={{
background: 'none', border: 'none', cursor: 'pointer', padding: '0.4rem 0',
color: 'var(--text2)', fontSize: '0.88rem', display: 'flex', alignItems: 'center', gap: '0.35rem',
}}
onClick={() => setShowArchive((v) => !v)}
>
<span style={{ fontSize: '0.75rem' }}>{showArchive ? '▼' : '▶'}</span>
Archiv ({archivedReports.length} abgeschlossene Meldung{archivedReports.length !== 1 ? 'en' : ''})
</button>
{showArchive && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '0.5rem' }}>
{archivedReports.map((rep) => <ReportCard key={rep.id} rep={rep} />)}
</div>
)}
</div>
)}
</>
)}
</section>
)
})()}
</>
)}
@ -642,6 +664,7 @@ export default function InboxPage() {
<ReportDetailModal
report={reportModal}
isSuperadmin={isSuperadmin}
isClubAdmin={isClubAdmin && !isPlatformAdmin}
onClose={() => setReportModal(null)}
onRefresh={load}
/>

View File

@ -1,6 +1,6 @@
// Shinkan Jinkendo Frontend Version
export const APP_VERSION = "0.8.92"
export const APP_VERSION = "0.8.93"
export const BUILD_DATE = "2026-05-11"
export const PAGE_VERSIONS = {
@ -27,8 +27,8 @@ export const PAGE_VERSIONS = {
ExerciseMediaEmbed: "1.1.0", // P-11: Legal-Hold-Placeholder statt Datei
ExerciseMediaThumbTile: "1.1.0", // P-11: Legal-Hold-Kachel statt Datei-Vorschau
ExerciseAttachmentMediaStrip: "1.2.0", // P-13: MediaPreviewModal (geteilt) + Melden im Viewer
InboxPage: "2.2.0", // P-13: Workflow-Balken, Wieder-öffnen, Kommentar, Fehleranzeigezeige
OrgInboxContext: "1.2.0", // P-13: contentReportsError exposed
InboxPage: "2.3.0", // P-13: Archiv-Trennung offen/abgeschlossen; Club-Admin Legal-Hold-Button
OrgInboxContext: "1.3.0", // P-13: isClubAdmin + isPlatformAdmin exposed
MediaPreviewModal: "1.0.0", // P-13: geteilter Medienvorschau-Dialog (Melden + Bearbeiten optional)
ReportContentModal: "1.2.0", // P-13: onSuccess callback fuer Badge-Update
}