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
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:
parent
4bc24b4caf
commit
f544975a6c
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user