feat(exercises): bump version to 0.8.65 and enhance club media copyright handling #27

Merged
Lars merged 1 commits from develop into main 2026-05-09 09:05:28 +02:00
4 changed files with 157 additions and 9 deletions

View File

@ -265,6 +265,8 @@ class ExerciseUpdate(BaseModel):
# §4.2: Übung → official — angehängte Datei-Assets anheben + Copyright (nur mit expliziter Bestätigung)
promote_attached_media_for_official: Optional[bool] = None
default_official_media_copyright: Optional[str] = Field(default=None, max_length=2000)
# Vereins-Übung: fehlende Copyrights an Datei-Assets nach Prompt-Text setzen (PUT-Retry)
default_club_media_copyright: Optional[str] = Field(default=None, max_length=2000)
@model_validator(mode="after")
def normalize_goal_execution(self):
@ -347,6 +349,7 @@ class ExerciseBulkMetadataPatch(BaseModel):
target_group_ids: Optional[list[int]] = Field(default=None, max_length=_MAX_BULK_RELATION_IDS_PER_KIND)
promote_attached_media_for_official: bool = False
default_official_media_copyright: Optional[str] = Field(default=None, max_length=2000)
default_club_media_copyright: Optional[str] = Field(default=None, max_length=2000)
@model_validator(mode="after")
def at_least_one_patch_field(self):
@ -649,10 +652,19 @@ def apply_official_exercise_media_rules(
)
def apply_club_exercise_media_copyright_rules(cur, exercise_id: int, next_visibility: str) -> None:
def apply_club_exercise_media_copyright_rules(
cur,
exercise_id: int,
next_visibility: str,
*,
default_club_media_copyright: Optional[str] = None,
) -> None:
"""
Vereins-sichtbare Übung: angehängte Archiv-Dateien müssen aktiv sein und einen Copyright-Vermerk haben
(wie bei offiziellen Übungen, ohne Sichtbarkeits-Promotion der Assets).
Fehlt das Copyright, kann der Client nach Nutzerbestätigung `default_club_media_copyright`
mitsenden (mind. 3 Zeichen) wird auf alle betroffenen Assets mit zu kurzem/leerem Vermerk gesetzt.
"""
nv = (next_visibility or "private").strip().lower()
if nv != "club":
@ -701,7 +713,11 @@ def apply_club_exercise_media_copyright_rules(cur, exercise_id: int, next_visibi
},
)
if missing_cr:
if not missing_cr:
return
default_cr = _normalize_media_copyright_notice(default_club_media_copyright)
if len(default_cr) < _MIN_OFFICIAL_MEDIA_COPYRIGHT_LEN:
raise HTTPException(
status_code=422,
detail={
@ -714,6 +730,61 @@ def apply_club_exercise_media_copyright_rules(cur, exercise_id: int, next_visibi
},
)
ids = [int(x["media_asset_id"]) for x in missing_cr]
ph = ",".join(["%s"] * len(ids))
cur.execute(
f"""
UPDATE media_assets
SET copyright_notice = %s, updated_at = NOW()
WHERE id IN ({ph})
AND (
copyright_notice IS NULL
OR LENGTH(TRIM(copyright_notice)) < %s
)
""",
(default_cr, *ids, _MIN_OFFICIAL_MEDIA_COPYRIGHT_LEN),
)
rows2 = _fetch_exercise_linked_file_assets(cur, exercise_id)
missing_after: List[Dict[str, Any]] = []
blocking_after: List[Dict[str, Any]] = []
for r in rows2:
aid = int(r["id"])
lc = (r.get("lifecycle_state") or "").strip().lower()
cr = _normalize_media_copyright_notice(r.get("copyright_notice"))
if lc != "active":
blocking_after.append({"media_asset_id": aid})
continue
if len(cr) < _MIN_OFFICIAL_MEDIA_COPYRIGHT_LEN:
missing_after.append(
{
"media_asset_id": aid,
"original_filename": r.get("original_filename"),
}
)
if blocking_after:
raise HTTPException(
status_code=422,
detail={
"code": "CLUB_MEDIA_LIFECYCLE",
"message": "Archiv-Medium nach Copyright-Update nicht mehr aktiv.",
"media_assets": blocking_after,
},
)
if missing_after:
raise HTTPException(
status_code=422,
detail={
"code": "CLUB_MEDIA_COPYRIGHT_REQUIRED",
"message": (
f"Copyright konnte nicht auf alle Dateien angewendet werden "
f"(mind. {_MIN_OFFICIAL_MEDIA_COPYRIGHT_LEN} Zeichen pro Eintrag prüfen)."
),
"media_assets": missing_after,
},
)
def _abs_media_path(file_path_db: str, media_root: Path) -> Optional[Path]:
if not file_path_db or file_path_db.startswith("http"):
@ -1322,7 +1393,12 @@ def bulk_patch_exercises_metadata(
if (next_vis or "").strip().lower() == "club":
try:
apply_club_exercise_media_copyright_rules(cur, ex_id, next_vis)
apply_club_exercise_media_copyright_rules(
cur,
ex_id,
next_vis,
default_club_media_copyright=body.default_club_media_copyright,
)
except HTTPException as he:
d = he.detail
entry: Dict[str, Any] = {"id": ex_id}
@ -1913,6 +1989,7 @@ def update_exercise(
raw_promo = data.pop("promote_attached_media_for_official", None)
promote_media_flag = raw_promo is True
default_official_copy = data.pop("default_official_media_copyright", None)
default_club_copy = data.pop("default_club_media_copyright", None)
merged_rich = {fld: rich_row.get(fld) for fld in RICH_HTML_EXERCISE_FIELDS}
for fld in RICH_HTML_EXERCISE_FIELDS:
@ -1993,7 +2070,12 @@ def update_exercise(
assign_exercise_relations(cur, conn, exercise_id, data, do_commit=False)
try:
apply_club_exercise_media_copyright_rules(cur, exercise_id, next_vis)
apply_club_exercise_media_copyright_rules(
cur,
exercise_id,
next_vis,
default_club_media_copyright=default_club_copy,
)
except HTTPException:
conn.rollback()
raise

View File

@ -60,3 +60,49 @@ def test_apply_club_exercise_media_copyright_ok() -> None:
apply_club_exercise_media_copyright_rules(object(), 42, "club")
finally:
exercises_mod._fetch_exercise_linked_file_assets = orig
def test_apply_club_exercise_media_copyright_applies_default() -> None:
"""Nach Bestätigung setzt der Client default_club_media_copyright — Backend schreibt auf Assets."""
orig_fetch = exercises_mod._fetch_exercise_linked_file_assets
calls = {"n": 0}
def mock_fetch(_cur, _eid: int):
calls["n"] += 1
if calls["n"] == 1:
return [
{
"id": 10,
"visibility": "private",
"club_id": 1,
"lifecycle_state": "active",
"copyright_notice": "",
"original_filename": "x.jpg",
}
]
return [
{
"id": 10,
"visibility": "private",
"club_id": 1,
"lifecycle_state": "active",
"copyright_notice": "© Testverein",
"original_filename": "x.jpg",
}
]
class MockCur:
def execute(self, *args, **kwargs):
pass
exercises_mod._fetch_exercise_linked_file_assets = mock_fetch
try:
apply_club_exercise_media_copyright_rules(
MockCur(),
42,
"club",
default_club_media_copyright="© Testverein",
)
assert calls["n"] == 2
finally:
exercises_mod._fetch_exercise_linked_file_assets = orig_fetch

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.64"
APP_VERSION = "0.8.65"
BUILD_DATE = "2026-05-08"
DB_SCHEMA_VERSION = "20260508049"
@ -17,7 +17,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
"exercises": "2.19.0", # Inline-Medien §11: Fließtext-Platzhalter exercise_media.id, Normalisierung/Validierung; CREATE ohne Platzhalter
"exercises": "2.19.1", # Verein: PUT default_club_media_copyright + Prompt beim Speichern (fehlende File-Asset-Copyrights)
"training_units": "0.2.0",
"training_programs": "0.1.0",
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
@ -29,6 +29,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.65",
"date": "2026-05-08",
"changes": [
"Übung auf Verein: fehlende Copyrights an Datei-Assets wieder per Bestätigung + Eingabe beim Speichern setzbar (PUT default_club_media_copyright; Bulk-PATCH ebenfalls)",
],
},
{
"version": "0.8.64",
"date": "2026-05-08",

View File

@ -654,10 +654,23 @@ function ExerciseFormPage() {
firstErr.code === 'CLUB_MEDIA_COPYRIGHT_REQUIRED' &&
firstErr.payload?.media_assets
) {
alert(
'Vereinsöffentliche Übungen brauchen bei jeder verknüpften Datei einen Copyright-Vermerk (mind. 3 Zeichen). Bitte in der Medienbibliothek oder den Mediendetails nachtragen.',
const miss = firstErr.payload.media_assets.length
const msg =
`Vereinsöffentliche Übungen brauchen bei jeder verknüpften Datei einen Copyright-Vermerk (mind. 3 Zeichen). ` +
`${miss} Datei(en) sind noch ohne ausreichenden Vermerk. ` +
`Beim Speichern einen gemeinsamen Vermerk für diese Dateien setzen?`
if (!window.confirm(msg)) throw firstErr
const defaultCopyright = window.prompt(
'Copyright-Vermerk für die betroffenen Dateien (mind. 3 Zeichen):',
'© ',
)
throw firstErr
if (!defaultCopyright || String(defaultCopyright).trim().length < 3) {
alert('Mindestens 3 Zeichen für den Copyright-Vermerk.')
throw firstErr
}
await saveOnce({
default_club_media_copyright: String(defaultCopyright).trim(),
})
} else if (firstErr.status === 422 && firstErr.code === 'CLUB_MEDIA_LIFECYCLE') {
alert(
'Speichern nicht möglich: mindestens ein verknüpftes Medium ist nicht aktiv (Papierkorb). Bitte reaktivieren oder entfernen.',