feat(media-journal): add Superadmin media journal endpoint and UI integration
Some checks failed
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 10s
Test Suite / playwright-tests (push) Failing after 1m5s

This commit is contained in:
Lars 2026-05-11 09:24:39 +02:00
parent 4bc24b4caf
commit f544975a6c
6 changed files with 302 additions and 4 deletions

View File

@ -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}

View File

@ -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",

View File

@ -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;
}

View File

@ -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 (
<div className="media-library__journal-field">
<span className="media-library__journal-field-label">{label}</span>
<span className={`media-library__journal-field-val${val ? ' media-library__journal-field-val--yes' : ' media-library__journal-field-val--no'}`}>
{val ? 'Ja' : 'Nein'}
</span>
{context ? <span className="media-library__journal-field-context">{context}"</span> : null}
</div>
)
}
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() {
</div>
</div>
{isSuperadmin ? (
<div className="media-library__lc-block">
<div className="media-library__lc-title">Medienjournal</div>
<button
type="button"
className="btn btn-secondary"
disabled={journalLoading}
onClick={() => openJournal(modal)}
style={{ display: 'flex', alignItems: 'center', gap: 6 }}
>
<ScrollText size={14} aria-hidden />
Einwilligungs-Journal
</button>
</div>
) : null}
{modal.permissions?.superadmin_lifecycle ? (
<div className="media-library__lc-block media-library__lc-block--danger">
<div className="media-library__lc-title">Superadmin</div>
@ -1334,6 +1378,107 @@ export default function MediaLibraryPage() {
</div>
</div>
) : null}
{journalModal ? (
<div className="media-library__overlay" role="dialog" aria-modal="true" aria-labelledby="journal-modal-title">
<div className="media-library__modal media-library__modal--wide">
<div className="media-library__modal-head">
<h2 id="journal-modal-title">
Journal · Medium #{journalModal.asset.id}
{journalModal.asset.original_filename ? ` · ${journalModal.asset.original_filename}` : ''}
</h2>
<button
type="button"
className="media-library__icon-btn"
onClick={() => setJournalModal(null)}
aria-label="Schließen"
>
<X size={22} />
</button>
</div>
<div className="media-library__modal-body">
<div className="media-library__meta-block">
<div>
<span className="media-library__meta-k">Rechtsstatus</span>
<span className="media-library__meta-v">{journalModal.asset.rights_status}</span>
</div>
<div>
<span className="media-library__meta-k">Sichtbarkeit</span>
<span className="media-library__meta-v">{visibilityUiLabel(journalModal.asset.visibility)}</span>
</div>
{journalModal.asset.copyright_notice ? (
<div>
<span className="media-library__meta-k">Copyright</span>
<span className="media-library__meta-v">{journalModal.asset.copyright_notice}</span>
</div>
) : null}
</div>
{journalModal.declarations.length === 0 ? (
<p className="media-library__hint">Noch keine Einwilligungserklärungen für dieses Medium erfasst.</p>
) : (
<div className="media-library__journal-list">
{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 (
<div key={d.id} className="media-library__journal-entry">
<div className="media-library__journal-entry-head">
<span className="media-library__journal-action">{d.action_type}</span>
<span className="media-library__journal-arrow"></span>
<span className="media-library__journal-vis">{visibilityUiLabel(d.target_visibility)}</span>
<span className="media-library__journal-date">
{new Date(d.declared_at).toLocaleString('de-DE')}
</span>
</div>
<div className="media-library__journal-entry-by">
{byLabel}
<span className="media-library__journal-version"> · {d.declaration_version}</span>
</div>
<div className="media-library__journal-fields">
<JournalBoolField label="Rechteinhaber bestätigt" val={d.rights_holder_confirmed} />
<JournalBoolField label="Erkennbare Personen" val={d.contains_identifiable_persons} />
{d.contains_identifiable_persons ? (
<JournalBoolField
label="Einwilligung Personen"
val={d.person_consent_confirmed}
context={d.person_consent_context}
/>
) : null}
<JournalBoolField label="Minderjährige" val={d.contains_minors} />
{d.contains_minors ? (
<JournalBoolField
label="Erziehungsberechtigte Einwilligung"
val={d.parental_consent_confirmed}
context={d.parental_consent_context}
/>
) : null}
<JournalBoolField label="Musik" val={d.contains_music} />
{d.contains_music ? (
<JournalBoolField
label="Musikrechte"
val={d.music_rights_confirmed}
context={d.music_rights_context}
/>
) : null}
<JournalBoolField label="Drittinhalte" val={d.contains_third_party_content} />
{d.contains_third_party_content ? (
<JournalBoolField
label="Drittrechte bestätigt"
val={d.third_party_rights_confirmed}
context={d.third_party_rights_context}
/>
) : null}
</div>
</div>
)
})}
</div>
)}
</div>
</div>
</div>
) : null}
</div>
)
}

View File

@ -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,

View File

@ -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
}