diff --git a/README.md b/README.md index 3233088..4d55215 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ An open-source, transfemme-focused voice-training practice companion. Contributions are welcome through issues and pull requests. Read [CONTRIBUTING.md](CONTRIBUTING.md), [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md), and [SECURITY.md](SECURITY.md) before contributing. -The [Product Vision](docs/product-vision.md) explains the long-term learning experience and non-negotiable product principles. The evidence, safety boundaries, and feature-design principles are public in the [Research and Practice Guide](docs/research-guide.md). It is the source of truth for training-related product changes. The [Implementation Roadmap](docs/roadmap.md) records the active academy milestones, decisions, and known limitations. The [Lesson Engine Architecture](docs/lesson-engine.md) documents the versioned content contract for contributors, and [Foundations Course: First Four Lessons](docs/foundations-lessons.md) documents the first real lesson set. The [iOS Readiness Plan](docs/ios-readiness.md) documents the decisions that keep a future native app possible without a rewrite. +The [Product Vision](docs/product-vision.md) explains the long-term learning experience and non-negotiable product principles. The evidence, safety boundaries, and feature-design principles are public in the [Research and Practice Guide](docs/research-guide.md). It is the source of truth for training-related product changes. The [Implementation Roadmap](docs/roadmap.md) records the active academy milestones, decisions, and known limitations. The [Lesson Engine Architecture](docs/lesson-engine.md) documents the versioned content contract, the [Educational Media Pipeline](docs/media-pipeline.md) documents governed teaching assets, and [Foundations Course: First Four Lessons](docs/foundations-lessons.md) documents the first real lesson set. The [iOS Readiness Plan](docs/ios-readiness.md) documents the decisions that keep a future native app possible without a rewrite. FemmeVoice is released under the [MIT License](LICENSE). diff --git a/docs/media-pipeline.md b/docs/media-pipeline.md index 55743bb..d33d63e 100644 --- a/docs/media-pipeline.md +++ b/docs/media-pipeline.md @@ -4,4 +4,37 @@ Milestone 6 treats educational media as versioned teaching content, not an untra Kind-specific publication requirements are enforced at the content boundary: images need alternative text; audio needs a transcript; video needs both transcript and captions. A replacement creates a new asset version so published lesson revisions remain reproducible. Localization assets link to the same conceptual asset id with their own locale and review. -The first validation fixture is the existing `public/academy/voice-pathway.jpg` illustration. Upload storage, author UI, and lesson asset references follow only after this contract is proven with real reviewed media. +The first validation fixture is the existing `public/academy/voice-pathway.jpg` illustration. Its source file, byte size, checksum, rights, alternative text, and review state are maintained as one governed metadata revision. + +## Lifecycle + +Media follows the same separation of duties as courses and lessons: + +1. An author creates or updates a private draft. +2. Review submission validates that the source is real rather than a placeholder, the checksum and byte size are present, rights are documented, and the required accessible alternative exists. +3. A reviewer records content, research, and accessibility checks. +4. A publisher releases an approved immutable revision. +5. Learners can receive only published revisions through `GET /api/academy/media`. + +Incomplete drafts are intentionally saveable. This lets an author record a useful asset before a transcript, caption file, rights confirmation, or final binary is ready, without weakening the publication boundary. + +## Admin API + +- `GET /api/admin/academy/media` lists revisions for Academy roles. +- `GET /api/admin/academy/media/:id/:version/:locale` loads one revision. +- `PUT /api/admin/academy/media/:id/:version/:locale` saves a private author draft. +- `PUT .../submit-review` requests review after publication-readiness validation. +- `PUT .../review` records the independent reviewer decision. +- `PUT .../publish` publishes an approved immutable revision. + +The Admin Media Library provides structured fields for identity, source, checksum, rights, kind-specific accessibility, replacement lineage, and localization lineage. It does not expose raw JSON as the normal workflow. + +## Public Delivery + +`GET /api/academy/media` is anonymous, read-only, version-aware, and cacheable. It returns the latest published revision for each asset identity and locale and omits private review notes and identities. Drafts and unpublished revisions never enter the manifest. + +Binary storage remains separate from metadata governance. This slice accepts reviewed local paths or HTTPS sources; it does not put educational media into the encrypted learner-recording vault. A later Milestone 6 slice must choose an upload/CDN strategy before large production audio or video is accepted. + +## Next Boundary + +Lesson blocks still use their existing bundled source paths. The next slice will add deterministic governed asset references and a resolver that prefers the published manifest while retaining safe bundled recovery content. Interactive Practice remains a separate backlog initiative. diff --git a/docs/roadmap.md b/docs/roadmap.md index 4180096..f2a1520 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,18 +2,18 @@ **Status:** living implementation guide **Last updated:** 17 July 2026 -**Current development phase:** Milestone 5 authoring closure, ready for validation before Milestone 6 resumes +**Current development phase:** Milestone 6 educational media governance and delivery ## Active Development | Item | Current state | | --- | --- | -| Current milestone | Milestone 5 authoring closure: blank creation and explicit revision paths | -| Current version | `v0.3.30` | -| Current working branch | `fix/academy-authoring-closure` | -| Active pull request(s) | PR #19 - Academy authoring closure; PR #18 is integrated into `main`. | +| Current milestone | Milestone 6 - Educational Media Pipeline | +| Current version | `v0.3.32` | +| Current working branch | `feat/academy-media-library` | +| Active pull request(s) | PR #20 - Governed Academy media library; PR #19 is integrated into `main`. | | Base branch | `main` is canonical. | -| Next planned milestone | Resume Milestone 6 - Educational Media Pipeline | +| Next planned milestone | Continue Milestone 6 with deterministic lesson asset references and public resolution. | | Overall completion estimate | About 40% of the long-term Academy vision; engine, history, governance, authoring, and delivery are complete, while media, remaining curriculum, and coaching remain substantial work. | ## Vision @@ -54,6 +54,8 @@ The definitive product direction is the [Product Vision](product-vision.md). Voi - `v0.3.28` - Milestone 5 end-to-end governance validation - `v0.3.29` - Versioned educational media contract - `v0.3.30` - Blank Academy authoring and immutable course/lesson revision paths +- `v0.3.31` - Governed Academy media library and publication workflow +- `v0.3.32` - Governed media-library pull-request readiness Update this list whenever a versioned change is pushed so milestones, pull requests, and releases remain easy to correlate. @@ -430,6 +432,11 @@ Milestone 5 ends when a contributor can comfortably maintain the complete Founda **In progress** - [x] Versioned media contract covers locale, source, rights, accessibility metadata, and three-part review. - [x] Existing Foundations voice-pathway illustration validates as the first real asset fixture. +- [x] Read-only public media manifest exposes only published revisions and carries cache directives. +- [x] Admin Media Library supports draft, review, publish, replacement, and localization metadata workflows. +- [x] Drafts may be incomplete, while review submission rejects placeholders and missing rights or accessible alternatives. +- [ ] Lesson media blocks reference deterministic governed asset revisions and resolve them through the public manifest. +- [ ] Validate replacement and localization resolution with real Foundations lesson content. ### Milestone 7 - Remaining Foundations Lessons @@ -470,6 +477,7 @@ The existing `interactive_exercise` block remains suitable for simple learner-le - Academy history must remain a separate versioned contract. Milestone 4A establishes useful local history; Milestone 4B owns migration, retention, export, deletion, multi-device conflict handling, and explicit sync consent. - Existing production course records require the documented one-time version-index migration before a second course revision is saved. - Public media needs a storage/CDN strategy; do not put large course video into the existing encrypted-recording vault. +- Media metadata governance is implemented, but binary upload/storage is intentionally pending a dedicated storage decision. Current assets use reviewed local or HTTPS sources. - Lesson authoring must enforce captions, transcripts, evidence, and safety metadata, or content quality will drift. - Community features require funded human moderation and safeguarding; they are not a learner MVP dependency. - Native iOS work depends on later API-token and audio-provider boundaries described in the iOS readiness plan. @@ -530,3 +538,4 @@ The existing `interactive_exercise` block remains suitable for simple learner-le - **2026-07-17:** Added an isolated Docker Compose staging stack and guarded development-account seed command for the author, reviewer, publisher, and administrator workflow. It is the required environment for the final Milestone 5 publication demonstration. - **2026-07-17:** Milestone 5 completed after a clean staging run proved author/reviewer/publisher permissions, draft privacy, four-lesson Foundations publication, cacheable public delivery, desktop/mobile learner rendering, bundled fallback behavior, and clean-volume reproducibility. - **2026-07-17:** A roadmap truth audit found missing blank-authoring controls and no practical revision path for published courses. The closure patch adds blank documents, explicit next-version actions, versioned course lineage, and deterministic lesson references before Milestone 6 resumes. +- **2026-07-17:** Milestone 6 begins with a governed media library. Authors can save incomplete private drafts, but only real checksummed assets with complete rights and kind-specific accessibility metadata may enter role-separated review and publication. Published metadata is exposed through a cacheable read-only manifest; lesson resolution follows in the next focused slice. diff --git a/docs/staging-academy.md b/docs/staging-academy.md index 9279e05..eb493ad 100644 --- a/docs/staging-academy.md +++ b/docs/staging-academy.md @@ -11,3 +11,5 @@ The seed script refuses to run unless `FEMMEVOICE_ENV=staging`; Compose uses a s The local Compose profile explicitly disables the cookie `Secure` attribute because it is served over loopback HTTP. Production defaults to secure cookies and must not reuse this override. The Milestone 5 validation used separate author, reviewer, and publisher sessions. Draft and approved-but-unpublished records returned an empty public catalogue. After all four Foundations lessons and the course were published, the catalogue returned one course with four lessons and `Cache-Control: public, max-age=60, stale-while-revalidate=300`. A clean `down -v` and restart recreated an empty isolated database and the accounts seeded successfully again. + +Milestone 6 media validation uses the same identities in the **Educational media** section. Save a private asset draft as the author, submit it only after its source, checksum, rights, and accessible alternative are complete, approve all three review areas as the reviewer, and publish as the publisher. `GET /api/academy/media` must remain empty before publication and return the published revision with `Cache-Control: public, max-age=300, stale-while-revalidate=3600` afterward. Published media fields are read-only; replacements and localizations begin as new private revisions. diff --git a/package-lock.json b/package-lock.json index c7b8bad..413ae10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "femmevoice", - "version": "0.3.30", + "version": "0.3.32", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "femmevoice", - "version": "0.3.30", + "version": "0.3.32", "dependencies": { "@vitejs/plugin-react": "^5.0.0", "lucide-react": "^0.468.0", diff --git a/package.json b/package.json index 91c64cd..72ed989 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "femmevoice", - "version": "0.3.30", + "version": "0.3.32", "private": true, "type": "module", "scripts": { diff --git a/server/academy_media.py b/server/academy_media.py index 08a4e16..eb1899c 100644 --- a/server/academy_media.py +++ b/server/academy_media.py @@ -1,33 +1,124 @@ from copy import deepcopy +import re +from urllib.parse import urlparse +MEDIA_SCHEMA_VERSION = 1 MEDIA_KINDS = {"audio", "video", "image", "document"} +MEDIA_STATES = {"draft", "review_requested", "in_review", "published"} +REVIEW_KEYS = ("content_checked", "research_checked", "accessibility_checked") -def validate_media_asset(asset): +def validate_media_asset(asset, require_review=True, publication_ready=True): if not isinstance(asset, dict): raise ValueError("Media asset must be an object.") - required = ("id", "version", "kind", "locale", "source", "title", "rights", "accessibility", "review") + required = ("id", "version", "kind", "locale", "source", "title", "mimeType", "byteSize", "checksum", "rights", "accessibility", "review") if any(key not in asset for key in required): raise ValueError("Media asset is missing required fields.") + if asset.get("schemaVersion", MEDIA_SCHEMA_VERSION) != MEDIA_SCHEMA_VERSION: + raise ValueError("Media asset schema version is unsupported.") if not _text(asset["id"], 120) or not isinstance(asset["version"], int) or asset["version"] < 1: raise ValueError("Media asset identity is invalid.") - if asset["kind"] not in MEDIA_KINDS or not _text(asset["locale"], 20) or not _text(asset["source"], 2000) or not _text(asset["title"], 300): + if asset["kind"] not in MEDIA_KINDS or not _text(asset["locale"], 20) or not _safe_source(asset["source"]) or not _text(asset["title"], 300): raise ValueError("Media asset metadata is invalid.") - if not isinstance(asset["rights"], dict) or not _text(asset["rights"].get("owner"), 300) or not _text(asset["rights"].get("license"), 300): + if not _text(asset["mimeType"], 120) or not isinstance(asset["byteSize"], int) or not 0 <= asset["byteSize"] <= 5_000_000_000: + raise ValueError("Media type or byte size is invalid.") + if not isinstance(asset["checksum"], str) or not re.fullmatch(r"sha256:[a-fA-F0-9]{64}", asset["checksum"]): + raise ValueError("Media assets need a SHA-256 checksum.") + _validate_rights(asset["rights"], require_complete=publication_ready) + _validate_accessibility(asset["kind"], asset["accessibility"], require_complete=publication_ready) + if publication_ready and (asset["byteSize"] < 1 or asset["checksum"] == f"sha256:{'0' * 64}" or "/placeholder." in asset["source"]): + raise ValueError("Replace placeholder media with a real, checksummed asset before review.") + _validate_relations(asset.get("relations", {})) + review = validate_media_review(asset["review"], require_approved=require_review) + clean = deepcopy(asset) + clean["schemaVersion"] = MEDIA_SCHEMA_VERSION + clean["review"] = review + clean["relations"] = deepcopy(asset.get("relations", {})) + return clean + + +def validate_media_review(review, require_approved=True): + if not isinstance(review, dict): + raise ValueError("Media review must be an object.") + clean = {key: review.get(key) is True for key in REVIEW_KEYS} + if require_approved and not all(clean.values()): + raise ValueError("Content, research, and accessibility review are required.") + decision = review.get("decision", "approved" if all(clean.values()) else "pending") + if decision not in {"pending", "approved", "changes_requested"}: + raise ValueError("Media review decision is invalid.") + note = review.get("note", "") + if not isinstance(note, str) or len(note) > 4000: + raise ValueError("Media review note is too long.") + return {**clean, "decision": decision, "note": note.strip()} + + +def media_review_result_status(decision): + return "in_review" if decision == "approved" else "draft" + + +def build_public_media_manifest(records): + latest = {} + for record in records: + asset = record.get("asset") or {} + identity = (asset.get("id"), asset.get("locale")) + version = asset.get("version", 0) + if identity[0] and identity[1] and version > latest.get(identity, {}).get("version", 0): + latest[identity] = asset + assets = [] + for asset in latest.values(): + public = deepcopy(asset) + public["review"] = {key: public.get("review", {}).get(key) is True for key in REVIEW_KEYS} + assets.append(public) + assets.sort(key=lambda item: (item["id"], item["locale"])) + return {"schemaVersion": MEDIA_SCHEMA_VERSION, "assets": assets} + + +def _validate_rights(rights, require_complete=True): + if not isinstance(rights, dict): + raise ValueError("Media rights metadata must be an object.") + if require_complete and (not _text(rights.get("owner"), 300) or not _text(rights.get("license"), 300)): raise ValueError("Media asset rights and license are required.") - access = asset["accessibility"] + if rights.get("sourceUrl") and not _safe_source(rights["sourceUrl"], local_allowed=False): + raise ValueError("Media rights source URL is invalid.") + if rights.get("attribution") is not None and not _optional_text(rights.get("attribution"), 1000): + raise ValueError("Media attribution is invalid.") + + +def _validate_accessibility(kind, access, require_complete=True): if not isinstance(access, dict): raise ValueError("Media accessibility metadata is required.") - if asset["kind"] == "image" and not _text(access.get("alternative"), 2000): + if require_complete and kind == "image" and not _text(access.get("alternative"), 2000): raise ValueError("Images need alternative text.") - if asset["kind"] in {"audio", "video"} and not _text(access.get("transcript"), 12000): + if require_complete and kind in {"audio", "video"} and not _text(access.get("transcript"), 12000): raise ValueError("Audio and video need a transcript.") - if asset["kind"] == "video" and not _text(access.get("captions"), 2000): - raise ValueError("Video needs captions.") - review = asset["review"] - if not isinstance(review, dict) or not all(review.get(key) is True for key in ("content_checked", "research_checked", "accessibility_checked")): - raise ValueError("Content, research, and accessibility review are required.") - return deepcopy(asset) + if require_complete and kind == "video" and not _safe_source(access.get("captions")): + raise ValueError("Video needs a safe captions source.") + if access.get("longDescription") is not None and not _optional_text(access.get("longDescription"), 12000): + raise ValueError("Media long description is invalid.") + + +def _validate_relations(relations): + if not isinstance(relations, dict): + raise ValueError("Media relationships must be an object.") + for key in ("replaces", "localizationOf"): + reference = relations.get(key) + if reference is None: + continue + if not isinstance(reference, dict) or not _text(reference.get("id"), 120) or not isinstance(reference.get("version"), int) or reference["version"] < 1 or not _text(reference.get("locale"), 20): + raise ValueError(f"Media relationship {key} is invalid.") + + +def _safe_source(value, local_allowed=True): + if not _text(value, 2000): + return False + if local_allowed and value.startswith("/") and not value.startswith("//"): + return True + parsed = urlparse(value) + return parsed.scheme == "https" and bool(parsed.netloc) + + +def _optional_text(value, maximum): + return value is None or value == "" or _text(value, maximum) def _text(value, maximum): diff --git a/server/app.py b/server/app.py index a024466..578382f 100644 --- a/server/app.py +++ b/server/app.py @@ -21,6 +21,7 @@ from werkzeug.security import check_password_hash, generate_password_hash from academy_history import normalize_academy_history from academy_content import build_public_catalogue, can_submit_for_review, resolve_published_lesson_refs, review_result_status, validate_course_document, validate_lesson_document, validate_review +from academy_media import build_public_media_manifest, media_review_result_status, validate_media_asset, validate_media_review from reminder_logic import VALID_REMINDER_TONES, normalize_reminder_days ROOT = Path(__file__).resolve().parent.parent @@ -84,6 +85,7 @@ academy_history_collection = db["academy_history"] academy_lessons_collection = db["academy_lessons"] academy_courses_collection = db["academy_courses"] +academy_media_collection = db["academy_media"] recordings_bucket = GridFSBucket(db, bucket_name="private_recordings") progress_collection.create_index([("device_id", ASCENDING)]) progress_collection.create_index([("storage_key", ASCENDING)], unique=True) @@ -96,6 +98,8 @@ academy_lessons_collection.create_index([("lesson_id", ASCENDING), ("version", ASCENDING)], unique=True) academy_lessons_collection.create_index([("status", ASCENDING), ("updated_at", ASCENDING)]) academy_courses_collection.create_index([("course_id", ASCENDING), ("version", ASCENDING)], unique=True) +academy_media_collection.create_index([("asset_id", ASCENDING), ("version", ASCENDING), ("locale", ASCENDING)], unique=True) +academy_media_collection.create_index([("status", ASCENDING), ("updated_at", ASCENDING)]) @app.after_request @@ -113,7 +117,8 @@ def apply_security_headers(response): response.headers["Cross-Origin-Resource-Policy"] = "same-origin" if request.is_secure: response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" - if request.path.startswith("/api/auth/") or request.path.startswith("/api/privacy/") or request.path.startswith("/api/recordings") or request.path.startswith("/api/admin/") or (request.path.startswith("/api/academy/") and request.path != "/api/academy/content") or request.path.startswith("/api/account/academy-history"): + public_academy_paths = {"/api/academy/content", "/api/academy/media"} + if request.path.startswith("/api/auth/") or request.path.startswith("/api/privacy/") or request.path.startswith("/api/recordings") or request.path.startswith("/api/admin/") or (request.path.startswith("/api/academy/") and request.path not in public_academy_paths) or request.path.startswith("/api/account/academy-history"): response.headers["Cache-Control"] = "no-store" return response @@ -552,6 +557,124 @@ def public_academy_content(): return response +@app.get("/api/academy/media") +def public_academy_media(): + records = list(academy_media_collection.find({"status": "published"}, {"_id": 0, "asset": 1, "published_at": 1}).limit(1000)) + response = jsonify(build_public_media_manifest(records)) + response.headers["Cache-Control"] = "public, max-age=300, stale-while-revalidate=3600" + return response + + +@app.get("/api/admin/academy/media") +def list_academy_media_for_admin(): + user = user_from_request() + if not user or not user["academy_roles"]: + return auth_error("Academy media access is required.", 403) + records = list(academy_media_collection.find({}, {"_id": 0, "asset": 0}).sort("updated_at", -1).limit(1000)) + return jsonify({"roles": user["academy_roles"], "assets": records}) + + +@app.get("/api/admin/academy/media///") +def get_academy_media_for_admin(asset_id, version, locale): + user = user_from_request() + if not user or not user["academy_roles"]: + return auth_error("Academy media access is required.", 403) + record = academy_media_collection.find_one({"asset_id": asset_id, "version": version, "locale": locale}, {"_id": 0}) + if not record: + return auth_error("Academy media revision was not found.", 404) + return jsonify(record) + + +@app.put("/api/admin/academy/media///") +def save_academy_media_draft(asset_id, version, locale): + if not csrf_required(): + return auth_error("Your session expired. Refresh and try again.", 403) + user = academy_user_with_role("author") + if not user: + return auth_error("Academy author access is required.", 403) + if request.content_length and request.content_length > 100_000: + return auth_error("Media metadata is too large.", 413) + try: + asset = validate_media_asset((request.get_json(silent=True) or {}).get("asset"), require_review=False, publication_ready=False) + except ValueError as error: + return auth_error(str(error)) + if asset["id"] != asset_id or asset["version"] != version or asset["locale"] != locale: + return auth_error("Media path must match the asset id, version, and locale.") + existing = academy_media_collection.find_one({"asset_id": asset_id, "version": version, "locale": locale}) + if existing and existing.get("status") == "published": + return auth_error("Published media revisions are immutable. Create a new version or localization.", 409) + timestamp = now_iso() + academy_media_collection.update_one( + {"asset_id": asset_id, "version": version, "locale": locale}, + {"$set": {"asset_id": asset_id, "version": version, "locale": locale, "title": asset["title"], "kind": asset["kind"], "asset": asset, "status": "draft", "updated_at": timestamp, "authored_by": user["username"]}, "$setOnInsert": {"created_at": timestamp}}, + upsert=True, + ) + return jsonify({"ok": True, "status": "draft", "updated_at": timestamp}) + + +@app.put("/api/admin/academy/media////submit-review") +def submit_academy_media_for_review(asset_id, version, locale): + if not csrf_required(): + return auth_error("Your session expired. Refresh and try again.", 403) + user = academy_user_with_role("author") + record = academy_media_collection.find_one({"asset_id": asset_id, "version": version, "locale": locale}) + if not user: + return auth_error("Academy author access is required.", 403) + if not record: + return auth_error("Save the media draft before requesting review.", 404) + if not can_submit_for_review(record.get("status")): + return auth_error("Only a draft media revision can be submitted for review.", 409) + try: + validate_media_asset(record["asset"], require_review=False) + except ValueError as error: + return auth_error(str(error)) + academy_media_collection.update_one({"_id": record["_id"]}, {"$set": {"status": "review_requested", "review_requested_at": now_iso(), "review_requested_by": user["username"], "updated_at": now_iso()}}) + return jsonify({"ok": True, "status": "review_requested"}) + + +@app.put("/api/admin/academy/media////review") +def review_academy_media(asset_id, version, locale): + if not csrf_required(): + return auth_error("Your session expired. Refresh and try again.", 403) + user = academy_user_with_role("reviewer") + record = academy_media_collection.find_one({"asset_id": asset_id, "version": version, "locale": locale}) + if not user: + return auth_error("Academy reviewer access is required.", 403) + if not record: + return auth_error("Academy media revision was not found.", 404) + if record.get("status") != "review_requested": + return auth_error("An author must submit this media revision for review first.", 409) + try: + review = validate_media_review((request.get_json(silent=True) or {}).get("review"), require_approved=True) + except ValueError as error: + return auth_error(str(error)) + review.update({"reviewed_by": user["username"], "reviewed_at": now_iso()}) + asset = {**record["asset"], "review": {key: review[key] for key in ("decision", "content_checked", "research_checked", "accessibility_checked", "note")}} + status = media_review_result_status(review["decision"]) + academy_media_collection.update_one({"_id": record["_id"]}, {"$set": {"asset": asset, "status": status, "review": review, "updated_at": now_iso()}}) + return jsonify({"ok": True, "status": status, "review": review}) + + +@app.put("/api/admin/academy/media////publish") +def publish_academy_media(asset_id, version, locale): + if not csrf_required(): + return auth_error("Your session expired. Refresh and try again.", 403) + user = academy_user_with_role("publisher") + record = academy_media_collection.find_one({"asset_id": asset_id, "version": version, "locale": locale}) + if not user: + return auth_error("Academy publisher access is required.", 403) + if not record: + return auth_error("Academy media revision was not found.", 404) + if record.get("status") != "in_review" or record.get("review", {}).get("decision") != "approved": + return auth_error("An approved media review is required before publishing.", 409) + try: + validate_media_asset(record["asset"], require_review=True) + except ValueError as error: + return auth_error(str(error), 409) + academy_media_collection.update_one({"_id": record["_id"]}, {"$set": {"status": "published", "published_at": now_iso(), "published_by": user["username"], "updated_at": now_iso()}}) + return jsonify({"ok": True, "status": "published"}) + + @app.get("/api/admin/academy/lessons") def list_academy_lessons_for_admin(): user = user_from_request() diff --git a/server/test_academy_media.py b/server/test_academy_media.py index 6ffcf40..a71f997 100644 --- a/server/test_academy_media.py +++ b/server/test_academy_media.py @@ -1,14 +1,70 @@ import unittest -from academy_media import validate_media_asset +from academy_media import build_public_media_manifest, media_review_result_status, validate_media_asset, validate_media_review + + +def illustration(version=1, locale="en"): + return { + "schemaVersion": 1, + "id": "voice-pathway", + "version": version, + "kind": "image", + "locale": locale, + "source": f"/academy/voice-pathway-v{version}.jpg", + "title": "A simple sound pathway", + "mimeType": "image/jpeg", + "byteSize": 156938, + "checksum": "sha256:f6fda2493a5d8b93f7e918d5ae677efa51141c4f93a128e83cc1fbe5e1964c8f", + "rights": {"owner": "FemmeVoice", "license": "MIT project asset", "attribution": "Generated for FemmeVoice"}, + "accessibility": {"alternative": "A simplified side profile shows airflow from the lungs through the throat and mouth."}, + "relations": {}, + "review": {"decision": "approved", "content_checked": True, "research_checked": True, "accessibility_checked": True}, + } class AcademyMediaTests(unittest.TestCase): def test_foundations_illustration_has_a_publishable_asset_record(self): - asset = {"id": "voice-pathway", "version": 1, "kind": "image", "locale": "en", "source": "/academy/voice-pathway.jpg", "title": "A simple sound pathway", "rights": {"owner": "FemmeVoice", "license": "MIT project asset"}, "accessibility": {"alternative": "A simplified side profile shows airflow from the lungs through the throat and mouth."}, "review": {"content_checked": True, "research_checked": True, "accessibility_checked": True}} + asset = illustration() + asset["rights"]["attribution"] = "" + asset["accessibility"]["longDescription"] = "" self.assertEqual(validate_media_asset(asset)["id"], "voice-pathway") def test_media_cannot_publish_without_kind_specific_accessibility(self): - asset = {"id": "example", "version": 1, "kind": "video", "locale": "en", "source": "/example.mp4", "title": "Example", "rights": {"owner": "FemmeVoice", "license": "Owned"}, "accessibility": {"transcript": "Transcript"}, "review": {"content_checked": True, "research_checked": True, "accessibility_checked": True}} + asset = illustration() + asset.update({"kind": "video", "mimeType": "video/mp4"}) with self.assertRaises(ValueError): validate_media_asset(asset) + + def test_incomplete_media_can_be_saved_as_a_draft_but_not_submitted(self): + asset = illustration() + asset["review"] = {"decision": "pending", "content_checked": False, "research_checked": False, "accessibility_checked": False} + self.assertEqual(validate_media_asset(asset, require_review=False)["review"]["decision"], "pending") + asset["accessibility"] = {} + with self.assertRaises(ValueError): + validate_media_asset(asset, require_review=False) + self.assertEqual(validate_media_asset(asset, require_review=False, publication_ready=False)["accessibility"], {}) + + def test_placeholder_is_draftable_but_cannot_enter_review(self): + asset = illustration() + asset.update({"source": "/academy/placeholder.jpg", "byteSize": 0, "checksum": f"sha256:{'0' * 64}"}) + self.assertEqual(validate_media_asset(asset, require_review=False, publication_ready=False)["source"], "/academy/placeholder.jpg") + with self.assertRaisesRegex(ValueError, "placeholder"): + validate_media_asset(asset, require_review=False) + + def test_review_and_relationships_are_strict(self): + self.assertEqual(media_review_result_status("approved"), "in_review") + self.assertEqual(media_review_result_status("changes_requested"), "draft") + with self.assertRaises(ValueError): + validate_media_review({"decision": "approved", "content_checked": True}, require_approved=True) + asset = illustration(2) + asset["relations"] = {"replaces": {"id": "voice-pathway", "version": 1, "locale": "en"}} + self.assertEqual(validate_media_asset(asset)["relations"]["replaces"]["version"], 1) + + def test_public_manifest_exposes_only_latest_locale_revision(self): + manifest = build_public_media_manifest([ + {"asset": illustration(1)}, + {"asset": illustration(2)}, + {"asset": illustration(1, "fi")}, + ]) + self.assertEqual([(item["locale"], item["version"]) for item in manifest["assets"]], [("en", 2), ("fi", 1)]) + self.assertNotIn("decision", manifest["assets"][0]["review"]) diff --git a/src/academy/AdminAcademy.jsx b/src/academy/AdminAcademy.jsx index 337bcfe..0f7c2b0 100644 --- a/src/academy/AdminAcademy.jsx +++ b/src/academy/AdminAcademy.jsx @@ -7,6 +7,7 @@ import LessonPlayer from "./LessonPlayer"; import { validateLesson } from "./schema"; import { BLOCK_TYPES, getBlockDefinition } from "./blockRegistry"; import { createBlankCourse, createBlankLesson, createNextCourseRevision, createNextLessonRevision } from "./authoringTemplates"; +import AdminMediaLibrary from "./AdminMediaLibrary"; const completionKinds = ["manual", "optional", "response", "quiz", "activity"]; @@ -247,6 +248,7 @@ export default function AdminAcademy({ roles }) { + ; } diff --git a/src/academy/AdminMediaLibrary.jsx b/src/academy/AdminMediaLibrary.jsx new file mode 100644 index 0000000..dec4566 --- /dev/null +++ b/src/academy/AdminMediaLibrary.jsx @@ -0,0 +1,112 @@ +import { useEffect, useMemo, useState } from "react"; +import { CopyPlus, Image as ImageIcon, Languages, Plus, Save, Send, ShieldCheck, Stamp } from "lucide-react"; +import { listAcademyAdminMedia, loadAcademyAdminMedia, publishAcademyAdminMedia, reviewAcademyAdminMedia, saveAcademyAdminMedia, submitAcademyAdminMediaForReview } from "../api"; +import { createBlankMediaAsset, createMediaLocalization, createNextMediaRevision, MEDIA_KINDS, validateMediaAsset } from "./mediaSchema"; + +const EMPTY_REVIEW = { decision: "approved", content_checked: false, research_checked: false, accessibility_checked: false, note: "" }; + +export default function AdminMediaLibrary({ roles }) { + const [records, setRecords] = useState([]); + const [asset, setAsset] = useState(null); + const [assetStatus, setAssetStatus] = useState("draft"); + const [review, setReview] = useState(EMPTY_REVIEW); + const [saved, setSaved] = useState(false); + const [status, setStatus] = useState("Loading media library..."); + const draftValidation = useMemo(() => validateMediaAsset(asset), [asset]); + const publicationValidation = useMemo(() => validateMediaAsset(asset, { publicationReady: true }), [asset]); + const editable = assetStatus === "draft" && roles.includes("author"); + + useEffect(() => { refresh(); }, []); + + async function refresh() { + try { + const payload = await listAcademyAdminMedia(); + setRecords(payload.assets ?? []); + setStatus(payload.assets?.length ? "" : "No governed media assets yet."); + } catch (error) { setStatus(error.message); } + } + + function startAsset(next, message) { + setAsset(next); + setAssetStatus("draft"); + setReview({ ...EMPTY_REVIEW }); + setSaved(false); + setStatus(message); + } + + async function openRecord(record) { + try { + const payload = await loadAcademyAdminMedia(record.asset_id, record.version, record.locale); + setAsset(payload.asset); + setAssetStatus(payload.status); + setReview(payload.review ?? payload.asset.review ?? EMPTY_REVIEW); + setSaved(true); + setStatus(`Editing ${payload.asset.title}, version ${payload.asset.version} (${payload.asset.locale}).`); + } catch (error) { setStatus(error.message); } + } + + function update(mutator) { + const next = structuredClone(asset); + mutator(next); + setAsset(next); + setSaved(false); + } + + async function saveDraft() { + if (!draftValidation.valid || !roles.includes("author")) return; + try { + setStatus("Saving media draft..."); + await saveAcademyAdminMedia(asset.id, asset.version, asset.locale, asset); + setAssetStatus("draft"); + setSaved(true); + setStatus("Media draft saved. It remains private until review and publication."); + refresh(); + } catch (error) { setStatus(error.message); } + } + + async function transition(action) { + try { + const result = action === "submit" + ? await submitAcademyAdminMediaForReview(asset.id, asset.version, asset.locale) + : action === "review" + ? await reviewAcademyAdminMedia(asset.id, asset.version, asset.locale, review) + : await publishAcademyAdminMedia(asset.id, asset.version, asset.locale); + setAssetStatus(result.status); + if (result.review) { + setReview(result.review); + setAsset((current) => ({ ...current, review: { decision: result.review.decision, content_checked: true, research_checked: true, accessibility_checked: true, note: result.review.note } })); + } + setStatus(action === "publish" ? "Published. This media revision is now immutable." : "Media workflow updated."); + refresh(); + } catch (error) { setStatus(error.message); } + } + + return
+
+
+ + {!asset ?

Create or choose an asset

Start with the illustration, audio, video, document, transcript, and rights information you already have. Review happens only after the draft is complete.

:
+

{formatStatus(assetStatus)}

{asset.title}

{assetStatus === "published" && roles.includes("author") &&
}
+
Media metadata
update((next) => { next.title = value; })} required /> update((next) => { next.id = value; })} required /> update((next) => { next.version = Number(value); })} required /> update((next) => { next.locale = value; })} required /> update((next) => { next.source = value; })} required /> update((next) => { next.mimeType = value; })} required /> update((next) => { next.byteSize = Number(value); })} required /> update((next) => { next.checksum = value; })} required />
+
Rights update((next) => { next.rights.owner = value; })} required /> update((next) => { next.rights.license = value; })} required /> update((next) => { next.rights.attribution = value; })} multiline /> update((next) => { next.rights.sourceUrl = value; })} />
Accessible alternatives{asset.kind === "image" && update((next) => { next.accessibility.alternative = value; })} multiline required />}{["audio", "video"].includes(asset.kind) && update((next) => { next.accessibility.transcript = value; })} multiline required />}{asset.kind === "video" && update((next) => { next.accessibility.captions = value; })} required />} update((next) => { next.accessibility.longDescription = value; })} multiline />
+

+
{editable && <>}{assetStatus === "review_requested" && roles.includes("reviewer") && transition("review")} />}{assetStatus === "in_review" && roles.includes("publisher") && }
+
} +
+ {status &&

{status}

} +
; +} + +function MediaReview({ review, onChange, onSubmit }) { + const ready = review.content_checked && review.research_checked && review.accessibility_checked; + return
Media review onChange({ ...review, note: value })} multiline />
; +} + +function MediaField({ label, value, onChange, multiline = false, required = false, ...props }) { + const controls = { value: value ?? "", onChange: (event) => onChange(event.target.value), required, ...props }; + return