diff --git a/backend/routers/media_assets.py b/backend/routers/media_assets.py index e135f67..3b8db3a 100644 --- a/backend/routers/media_assets.py +++ b/backend/routers/media_assets.py @@ -1692,3 +1692,55 @@ def get_legacy_rights_assets( total_row = cur.fetchone() total = int(r2d(total_row)["cnt"]) if total_row else 0 return {"total": total, "limit": limit, "offset": offset, "assets": assets} + + +@admin_rights_router.get("/assets/{asset_id}/journal") +def get_media_asset_journal( + asset_id: int, + tenant: TenantContext = Depends(get_tenant_context), +): + """P-06 Superadmin: Vollständiges Deklarationsjournal für ein Medium.""" + if not is_superadmin(tenant.global_role): + raise HTTPException(status_code=403, detail="Keine Berechtigung (Superadmin erforderlich)") + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT id, original_filename, visibility, rights_status, + rights_declared_for_visibility, rights_declared_at, + copyright_notice, mime_type, lifecycle_state, + uploaded_by_profile_id, created_at + FROM media_assets + WHERE id = %s + """, + (asset_id,), + ) + asset_row = cur.fetchone() + if not asset_row: + raise HTTPException(status_code=404, detail="Medium nicht gefunden") + asset = r2d(asset_row) + cur.execute( + """ + SELECT d.id, d.declared_at, d.action_type, d.target_visibility, + d.declaration_version, + d.rights_holder_confirmed, + d.contains_identifiable_persons, d.person_consent_confirmed, + d.person_consent_context, + d.contains_minors, d.parental_consent_confirmed, + d.parental_consent_context, + d.contains_music, d.music_rights_confirmed, + d.music_rights_context, + d.contains_third_party_content, d.third_party_rights_confirmed, + d.third_party_rights_context, + d.declared_by_profile_id, + p.username AS declared_by_username, + p.email AS declared_by_email + FROM media_asset_rights_declarations d + LEFT JOIN profiles p ON p.id = d.declared_by_profile_id + WHERE d.media_asset_id = %s + ORDER BY d.declared_at DESC + """, + (asset_id,), + ) + declarations = [r2d(r) for r in cur.fetchall()] + return {"asset": asset, "declarations": declarations} diff --git a/backend/version.py b/backend/version.py index 35012ba..84c5ce4 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.77" +APP_VERSION = "0.8.78" BUILD_DATE = "2026-05-11" DB_SCHEMA_VERSION = "20260511048" @@ -15,7 +15,7 @@ MODULE_VERSIONS = { "admin_users": "1.0.0", # GET /api/admin/users "platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT) "media_rights": "1.1.0", # P-06+: write_rights_declaration + 4 Kontext-Freitextfelder - "media_assets": "1.14.0", # P-06+: copyright_notice im Upload-Dialog; 4 Kontext-Felder in Bulk-Upload + PATCH + Re-Deklaration + "media_assets": "1.15.0", # P-06+: Superadmin-Journal GET /api/admin/media-rights/assets/{id}/journal "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", @@ -31,6 +31,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.78", + "date": "2026-05-11", + "changes": [ + "P-06 Superadmin Medienjournal: GET /api/admin/media-rights/assets/{id}/journal liefert vollstaendiges Deklarations-Log pro Medium; Frontend: Journal-Button im Bearbeitungs-Modal (nur Superadmin), scrollbare Timeline aller Einwilligungserklaerungen mit Kontext-Feldern.", + ], + }, { "version": "0.8.77", "date": "2026-05-11", diff --git a/frontend/src/app.css b/frontend/src/app.css index 7faf38b..d77bd4c 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -6530,3 +6530,92 @@ a.analysis-split__nav-item { gap: 0.5rem; flex-shrink: 0; } + +/* Superadmin Media Journal */ +.media-library__journal-list { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 8px; +} +.media-library__journal-entry { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px 14px; +} +.media-library__journal-entry-head { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 4px; +} +.media-library__journal-action { + font-size: 0.78rem; + font-weight: 600; + background: var(--accent); + color: #fff; + border-radius: 4px; + padding: 1px 7px; + letter-spacing: 0.01em; +} +.media-library__journal-arrow { + color: var(--text3); + font-size: 0.85rem; +} +.media-library__journal-vis { + font-size: 0.82rem; + font-weight: 600; + color: var(--text1); +} +.media-library__journal-date { + margin-left: auto; + font-size: 0.78rem; + color: var(--text3); + white-space: nowrap; +} +.media-library__journal-entry-by { + font-size: 0.8rem; + color: var(--text2); + margin-bottom: 8px; +} +.media-library__journal-version { + font-size: 0.74rem; + color: var(--text3); +} +.media-library__journal-fields { + display: flex; + flex-direction: column; + gap: 4px; +} +.media-library__journal-field { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; + font-size: 0.82rem; +} +.media-library__journal-field-label { + color: var(--text2); + min-width: 180px; +} +.media-library__journal-field-val { + font-weight: 600; + border-radius: 3px; + padding: 0 5px; + font-size: 0.78rem; +} +.media-library__journal-field-val--yes { + background: rgba(29, 158, 117, 0.12); + color: var(--accent-dark); +} +.media-library__journal-field-val--no { + background: rgba(0, 0, 0, 0.06); + color: var(--text2); +} +.media-library__journal-field-context { + font-size: 0.78rem; + color: var(--text2); + font-style: italic; +} diff --git a/frontend/src/pages/MediaLibraryPage.jsx b/frontend/src/pages/MediaLibraryPage.jsx index b90d119..315aee0 100644 --- a/frontend/src/pages/MediaLibraryPage.jsx +++ b/frontend/src/pages/MediaLibraryPage.jsx @@ -18,6 +18,7 @@ import { FileText, File, Upload, + ScrollText, } from 'lucide-react' import { useAuth } from '../context/AuthContext' import api from '../utils/api' @@ -231,6 +232,19 @@ function MediaTypeGlyph({ mimeType, compact }) { ) } +function JournalBoolField({ label, val, context }) { + if (val === null || val === undefined) return null + return ( +
+ {label} + + {val ? 'Ja' : 'Nein'} + + {context ? „{context}" : null} +
+ ) +} + export default function MediaLibraryPage() { const { user } = useAuth() const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' @@ -272,6 +286,8 @@ export default function MediaLibraryPage() { // P-06: Rechte-Dialog const [rightsDialogOpen, setRightsDialogOpen] = useState(false) const [pendingUploadFiles, setPendingUploadFiles] = useState(null) + const [journalModal, setJournalModal] = useState(null) + const [journalLoading, setJournalLoading] = useState(false) const mediaListFetchSeqRef = useRef(0) const gridTopAnchorRef = useRef(null) @@ -390,6 +406,18 @@ export default function MediaLibraryPage() { setModalDraft(null) } + const openJournal = async (it) => { + setJournalLoading(true) + try { + const data = await api.getMediaAssetJournal(it.id) + setJournalModal(data) + } catch (e) { + alert(e.message || String(e)) + } finally { + setJournalLoading(false) + } + } + const saveModal = async () => { if (!modal || !modalDraft) return const p = modal.permissions || {} @@ -1283,6 +1311,22 @@ export default function MediaLibraryPage() { + {isSuperadmin ? ( +
+
Medienjournal
+ +
+ ) : null} + {modal.permissions?.superadmin_lifecycle ? (
Superadmin
@@ -1334,6 +1378,107 @@ export default function MediaLibraryPage() {
) : null} + + {journalModal ? ( +
+
+
+

+ Journal · Medium #{journalModal.asset.id} + {journalModal.asset.original_filename ? ` · ${journalModal.asset.original_filename}` : ''} +

+ +
+
+
+
+ Rechtsstatus + {journalModal.asset.rights_status} +
+
+ Sichtbarkeit + {visibilityUiLabel(journalModal.asset.visibility)} +
+ {journalModal.asset.copyright_notice ? ( +
+ Copyright + {journalModal.asset.copyright_notice} +
+ ) : null} +
+ + {journalModal.declarations.length === 0 ? ( +

Noch keine Einwilligungserklärungen für dieses Medium erfasst.

+ ) : ( +
+ {journalModal.declarations.map((d) => { + const byLabel = d.declared_by_username || d.declared_by_email + || (d.declared_by_profile_id ? `Profil #${d.declared_by_profile_id}` : '—') + return ( +
+
+ {d.action_type} + + {visibilityUiLabel(d.target_visibility)} + + {new Date(d.declared_at).toLocaleString('de-DE')} + +
+
+ {byLabel} + · {d.declaration_version} +
+
+ + + {d.contains_identifiable_persons ? ( + + ) : null} + + {d.contains_minors ? ( + + ) : null} + + {d.contains_music ? ( + + ) : null} + + {d.contains_third_party_content ? ( + + ) : null} +
+
+ ) + })} +
+ )} +
+
+
+ ) : null} ) } diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 803aaa2..790468c 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -665,6 +665,10 @@ export async function bulkUploadMediaAssets(files, options = {}) { } /** Übung: bestehendes Archiv-Medium verknüpfen (`POST /api/exercises/{id}/media/from-asset`). */ +export async function getMediaAssetJournal(assetId) { + return request(`/api/admin/media-rights/assets/${assetId}/journal`) +} + export async function attachExerciseMediaFromAsset(exerciseId, body) { return request(`/api/exercises/${exerciseId}/media/from-asset`, { method: 'POST', @@ -1416,6 +1420,7 @@ export const api = { bulkMediaLifecycle, bulkPatchMediaAssets, bulkUploadMediaAssets, + getMediaAssetJournal, attachExerciseMediaFromAsset, listExerciseProgressionGraphs, getExerciseProgressionGraph, diff --git a/frontend/src/version.js b/frontend/src/version.js index 53fd247..c8b4055 100644 --- a/frontend/src/version.js +++ b/frontend/src/version.js @@ -1,6 +1,6 @@ // Shinkan Jinkendo Frontend Version -export const APP_VERSION = "0.8.77" +export const APP_VERSION = "0.8.78" export const BUILD_DATE = "2026-05-11" export const PAGE_VERSIONS = { @@ -20,7 +20,7 @@ export const PAGE_VERSIONS = { TrainingCoachPage: "1.0.0", AdminCatalogsPage: "2.2.0", TrainerContextsPage: "1.0.0", - MediaLibraryPage: "1.2.0", // P-06: RightsDeclarationDialog + Altbestand-Indikator + MediaLibraryPage: "1.3.0", // P-06: Superadmin Medienjournal-Modal ExerciseInlineFileMediaModal: "1.1.0", // P-06: RightsDeclarationDialog vor Upload ExerciseInlineEmbedModal: "1.1.0", // P-06: RightsDeclarationDialog vor Embed-Upload }