DGSVO Compliance update 1 #30

Merged
Lars merged 48 commits from develop into main 2026-05-12 06:34:15 +02:00
5 changed files with 191 additions and 98 deletions
Showing only changes of commit 5cf61289ec - Show all commits

View File

@ -152,9 +152,60 @@ class ContentReportLegalHoldBody(BaseModel):
# ─── Hilfsfunktionen ──────────────────────────────────────────────────────── # ─── Hilfsfunktionen ────────────────────────────────────────────────────────
def _assert_platform_admin(role: Optional[str]) -> None: def _assert_can_manage_report(cur, role: Optional[str], pid: int, report: dict) -> None:
if not is_platform_admin(role): """Platform-Admins: jede Meldung. Club-Admin: nur Meldungen zu Medien ihres Vereins."""
raise HTTPException(status_code=403, detail="Nur Plattform-Admins koennen Meldungen bearbeiten") 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: def _is_media_asset_visible_anonymous(cur, asset_id: int) -> bool:
@ -651,8 +702,9 @@ def get_content_report(
report_id: int, report_id: int,
tenant: TenantContext = Depends(get_tenant_context), tenant: TenantContext = Depends(get_tenant_context),
): ):
"""Detail-Ansicht einer Meldung fuer Plattform-Admins.""" """Detail-Ansicht einer Meldung fuer Plattform-Admins und zustaendige Club-Admins."""
_assert_platform_admin(tenant.global_role) pid = tenant.profile_id
role = tenant.global_role
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
@ -683,7 +735,9 @@ def get_content_report(
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
raise HTTPException(status_code=404, detail="Meldung nicht gefunden") 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}") @router.patch("/content-reports/{report_id}")
@ -695,9 +749,10 @@ def patch_content_report(
""" """
Status und Bearbeitungsnotiz einer Meldung aktualisieren. Status und Bearbeitungsnotiz einer Meldung aktualisieren.
Abschluss ohne Massnahme (resolved_no_action, rejected_invalid) erfordert resolution_note. 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 pid = tenant.profile_id
role = tenant.global_role
if body.status in ("resolved_no_action", "rejected_invalid") and not (body.resolution_note or "").strip(): if body.status in ("resolved_no_action", "rejected_invalid") and not (body.resolution_note or "").strip():
raise HTTPException( raise HTTPException(
@ -723,6 +778,7 @@ def patch_content_report(
if not row: if not row:
raise HTTPException(status_code=404, detail="Meldung nicht gefunden") raise HTTPException(status_code=404, detail="Meldung nicht gefunden")
old_report = r2d(row) old_report = r2d(row)
_assert_can_manage_report(cur, role, pid, old_report)
old_status = old_report["status"] old_status = old_report["status"]
old_note = (old_report.get("resolution_note") or "").strip() 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. 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. Der Reason-Code wird automatisch aus dem report_reason der Meldung abgeleitet.
Nach dem Setzen wird der Report-Status auf 'resolved_legal_hold' gesetzt. Nach dem Setzen wird der Report-Status auf 'resolved_legal_hold' gesetzt.
""" """
assert_superadmin_for_legal_hold(tenant.global_role)
pid = tenant.profile_id pid = tenant.profile_id
role = tenant.global_role
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
@ -828,6 +885,7 @@ def set_legal_hold_from_report(
) )
asset_id = int(report["target_id"]) 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") reason_code = _REASON_TO_HOLD_CODE.get(report["report_reason"], "other")
# P-11-Service aufrufen # P-11-Service aufrufen

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.92" APP_VERSION = "0.8.93"
BUILD_DATE = "2026-05-11" BUILD_DATE = "2026-05-11"
DB_SCHEMA_VERSION = "20260511053" DB_SCHEMA_VERSION = "20260511053"
@ -30,10 +30,20 @@ MODULE_VERSIONS = {
"membership": "1.0.0", "membership": "1.0.0",
"catalogs": "1.5.0", # Updated: Trainer Contexts API (Migration 012) "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 "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 = [ 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", "version": "0.8.92",
"date": "2026-05-11", "date": "2026-05-11",

View File

@ -115,8 +115,10 @@ export function OrgInboxProvider({ user, children }) {
canAccessOrgInbox: canAccess, canAccessOrgInbox: canAccess,
canAccessContentReports: canAccessReports, canAccessContentReports: canAccessReports,
isSuperadmin: user?.role === 'superadmin', 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> 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 [resolutionNote, setResolutionNote] = useState(report.resolution_note || '')
const [legalHoldNote, setLegalHoldNote] = useState('') const [legalHoldNote, setLegalHoldNote] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@ -279,13 +279,13 @@ function ReportDetailModal({ report, onClose, onRefresh, isSuperadmin }) {
Meldung abweisen Meldung abweisen
</button> </button>
</div> </div>
{isSuperadmin && !showLegalHoldForm && ( {(isSuperadmin || (isClubAdmin && report.target_visibility !== 'official')) && !showLegalHoldForm && (
<button <button
type="button" className="btn" type="button" className="btn"
style={{ background: 'rgba(216,90,48,0.12)', color: 'var(--danger)', border: '1px solid var(--danger)' }} style={{ background: 'rgba(216,90,48,0.12)', color: 'var(--danger)', border: '1px solid var(--danger)' }}
onClick={() => setShowLegalHoldForm(true)} onClick={() => setShowLegalHoldForm(true)}
> >
Legal Hold setzen (Superadmin) Legal Hold setzen {isSuperadmin ? '(Superadmin)' : '(Vereinsadmin)'}
</button> </button>
)} )}
{isSuperadmin && showLegalHoldForm && ( {isSuperadmin && showLegalHoldForm && (
@ -325,6 +325,8 @@ export default function InboxPage() {
canAccessOrgInbox, canAccessOrgInbox,
canAccessContentReports, canAccessContentReports,
isSuperadmin, isSuperadmin,
isPlatformAdmin,
isClubAdmin,
refreshOrgInbox, refreshOrgInbox,
inboxJoinRequests, inboxJoinRequests,
contentReports, contentReports,
@ -334,6 +336,7 @@ export default function InboxPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [acceptModal, setAcceptModal] = useState(null) const [acceptModal, setAcceptModal] = useState(null)
const [reportModal, setReportModal] = useState(null) const [reportModal, setReportModal] = useState(null)
const [showArchive, setShowArchive] = useState(false)
const load = useCallback(async () => { const load = useCallback(async () => {
if (!canAccessOrgInbox && !canAccessContentReports) { if (!canAccessOrgInbox && !canAccessContentReports) {
@ -471,43 +474,13 @@ export default function InboxPage() {
</section> </section>
)} )}
{/* Abschnitt 2: Inhaltsmeldungen (nur Plattform-Admins) */} {/* Abschnitt 2: Inhaltsmeldungen */}
{canAccessContentReports && ( {canAccessContentReports && (() => {
<section> const openReports = contentReports.filter((r) => r.status === 'submitted' || r.status === 'under_review')
<h2 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text2)' }}> const archivedReports = contentReports.filter((r) => r.status !== 'submitted' && r.status !== 'under_review')
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 ? ( function ReportCard({ rep }) {
<div className="card" style={{ padding: '1.25rem', borderLeft: '3px solid var(--danger)' }}> return (
<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 <div
key={rep.id} key={rep.id}
className="card" className="card"
@ -515,6 +488,7 @@ export default function InboxPage() {
padding: '1rem 1.25rem', padding: '1rem 1.25rem',
cursor: 'pointer', cursor: 'pointer',
borderLeft: rep.priority === 'high' ? '3px solid var(--danger)' : '3px solid transparent', 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)} onClick={() => setReportModal(rep)}
> >
@ -529,14 +503,7 @@ export default function InboxPage() {
{rep.target_filename || rep.target_exercise_name ? ` ${rep.target_filename || rep.target_exercise_name}` : ''} {rep.target_filename || rep.target_exercise_name ? ` ${rep.target_filename || rep.target_exercise_name}` : ''}
</span> </span>
</div> </div>
<span <span style={{ fontSize: '0.78rem', fontWeight: 500, color: STATUS_COLORS[rep.status], whiteSpace: 'nowrap' }}>
style={{
fontSize: '0.78rem',
fontWeight: 500,
color: STATUS_COLORS[rep.status],
whiteSpace: 'nowrap',
}}
>
{STATUS_LABELS[rep.status] || rep.status} {STATUS_LABELS[rep.status] || rep.status}
</span> </span>
</div> </div>
@ -549,11 +516,66 @@ export default function InboxPage() {
{formatWhen(rep.submitted_at || rep.created_at)} {formatWhen(rep.submitted_at || rep.created_at)}
</div> </div>
</div> </div>
))} )
}
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> </div>
)} )}
</section>
{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 <ReportDetailModal
report={reportModal} report={reportModal}
isSuperadmin={isSuperadmin} isSuperadmin={isSuperadmin}
isClubAdmin={isClubAdmin && !isPlatformAdmin}
onClose={() => setReportModal(null)} onClose={() => setReportModal(null)}
onRefresh={load} onRefresh={load}
/> />

View File

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