From c69af21c5edca180b7e2a4c41132db86ffe72b3d Mon Sep 17 00:00:00 2001 From: Emilia Vuorenmaa Date: Fri, 17 Jul 2026 16:01:26 +0300 Subject: [PATCH] fix: close Academy authoring workflow gaps --- docker-compose.staging.yml | 1 + docs/academy-content-delivery.md | 12 ++++ docs/roadmap.md | 65 ++++++++++++-------- docs/staging-academy.md | 2 + package-lock.json | 4 +- package.json | 2 +- server/academy_content.py | 53 +++++++++++++++- server/app.py | 58 +++++++++++------- server/migrate_academy_course_versions.py | 28 +++++++++ server/test_academy_content.py | 32 +++++++++- server/test_academy_course_migration.py | 39 ++++++++++++ src/academy/AdminAcademy.jsx | 70 ++++++++++++++++----- src/academy/authoringTemplates.js | 75 +++++++++++++++++++++++ src/api.js | 10 +-- src/styles.css | 8 ++- src/version.js | 2 +- test/academy-authoring.test.mjs | 27 ++++++++ test/lesson-engine.test.mjs | 3 +- 18 files changed, 411 insertions(+), 80 deletions(-) create mode 100644 server/migrate_academy_course_versions.py create mode 100644 server/test_academy_course_migration.py create mode 100644 src/academy/authoringTemplates.js create mode 100644 test/academy-authoring.test.mjs diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index f9823dd..cf46c89 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -11,6 +11,7 @@ services: MONGO_DB: femmevoice_staging LEGACY_MONGO_DB: femmevoice_legacy_staging SECRET_KEY: femmevoice-local-staging-only-change-me + SESSION_COOKIE_SECURE: "false" PUBLIC_BASE_URL: http://127.0.0.1:5180 FEMMEVOICE_ACADEMY_AUTHOR_USERNAMES: academy-author FEMMEVOICE_ACADEMY_REVIEWER_USERNAMES: academy-reviewer diff --git a/docs/academy-content-delivery.md b/docs/academy-content-delivery.md index bb351dd..59f6d50 100644 --- a/docs/academy-content-delivery.md +++ b/docs/academy-content-delivery.md @@ -9,3 +9,15 @@ Courses and lessons follow the same author, reviewer, publisher separation. An a The final Foundations migration must be performed through those identities in the Admin Academy. Automation must not silently mark educational content reviewed or published. Milestone 5 staging validation completed that workflow with the four current Foundations lessons. The learner UI displayed the governed four-lesson course on desktop and a 390px viewport without horizontal overflow. Empty or unavailable compatible catalogues continue to resolve to the bundled recovery content. + +## Immutable revisions + +Lessons and courses use independent positive integer versions. Creating a revision copies the published document into the next private version; the published record remains unchanged. When a course revision is published, the server records the exact lesson slug and version for every ordered lesson. Later lesson publications therefore cannot silently change an already published course. + +Existing course records from before `v0.3.30` become version 1. Before deploying code that creates a second course revision, run: + +```bash +FEMMEVOICE_CONFIRM_MIGRATION=academy-course-versions-v1 MONGO_URI="..." MONGO_DB="..." python server/migrate_academy_course_versions.py +``` + +The migration adds version 1 to legacy records, replaces the old single-field unique index with a compound course/version index, and does not delete content. diff --git a/docs/roadmap.md b/docs/roadmap.md index 98e6a5a..4180096 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 6 - Educational Media Pipeline, in progress +**Current development phase:** Milestone 5 authoring closure, ready for validation before Milestone 6 resumes ## Active Development | Item | Current state | | --- | --- | -| Current milestone | Milestone 6 - Educational Media Pipeline, in progress | -| Current version | `v0.3.29` | -| Current working branch | `feat/academy-media-foundation` | -| Active pull request(s) | Media-contract PR in preparation; PR #17 is integrated into `main`. | +| 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`. | | Base branch | `main` is canonical. | -| Next planned milestone | Milestone 6 - Educational Media Pipeline | +| Next planned milestone | Resume Milestone 6 - Educational Media Pipeline | | 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 @@ -53,6 +53,7 @@ The definitive product direction is the [Product Vision](product-vision.md). Voi - `v0.3.27` - Local Academy staging workflow - `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 Update this list whenever a versioned change is pushed so milestones, pull requests, and releases remain easy to correlate. @@ -98,6 +99,7 @@ This keeps history understandable without forcing unrelated work into a prematur | 2026-07-17 | Treat the first four Foundations lessons as an MVP slice, not the complete course. | Remaining lessons will follow after learner history and content operations are established. | | 2026-07-17 | Keep Academy learner history local and separate from legacy practice progress. | It gives learners useful reflection and deletion/export controls without silently expanding account data collection. | | 2026-07-17 | Make structured forms the normal lesson-authoring route; retain raw lesson JSON only as an advanced escape hatch. | It makes routine editing safer and more approachable without creating a second content schema or blocking carefully reviewed low-level changes. | +| 2026-07-17 | Version courses independently and pin lesson revisions at publication. | Published course paths must remain reproducible when a lesson or the course is revised later. | ## Milestones @@ -238,13 +240,13 @@ The generic engine is complete and has been validated with real, production-qual **Dependencies:** Milestones 2-3 **Acceptance criteria** -- [ ] Complete Milestones 4A and 4B below without changing the legacy `voice-training:progress` contract. -- [ ] Existing `voice-training:progress` version 1 remains readable and untouched. -- [ ] Data model is generic enough for future programs, optional confidence/ease history, transparent coach rules, and privacy-preserving aggregates. +- [x] Complete Milestones 4A and 4B below without changing the legacy `voice-training:progress` contract. +- [x] Existing `voice-training:progress` version 1 remains readable and untouched. +- [x] Data model is generic enough for future programs, optional confidence/ease history, transparent coach rules, and privacy-preserving aggregates. #### Milestone 4A - Local History And Reflection -**Status:** 👀 Ready for Review +**Status:** ✅ Completed **Goal:** Give learners a private, local record of completed lessons, active learning time, recently practised lessons, a short calendar, weekly summary, and optional notes. **Dependencies:** Milestones 2-3 @@ -271,7 +273,7 @@ The generic engine is complete and has been validated with real, production-qual - [x] Explicit enable/disable state, deterministic merge policy, account export, and deletion. - [x] Server-side payload limits, CSRF/auth checks, and privacy tests. -**Implemented, pending review** +**Implemented and merged** - [x] Account-only sync setting; disabling deletes the synced account copy after confirmation while retaining local history. - [x] Separate `academy_history` collection with a one-account record, versioned payload boundary, and no-store API responses. - [x] Deterministic client merge for sessions, notes, and lesson revisions. @@ -369,8 +371,8 @@ Milestone 5 ends when a contributor can comfortably maintain the complete Founda - [x] Available lesson picker, duplicate prevention, and keyboard-accessible earlier/later/remove ordering controls. - [x] Saved course draft browser and real Foundations course fixture. -**Remaining** -- [ ] Validate the complete course-management workflow against migrated Foundations content. +**Validated** +- [x] The complete course-management workflow passed the isolated Foundations publication run. #### Milestone 5G - Review And Publishing Workflow @@ -400,17 +402,19 @@ Milestone 5 ends when a contributor can comfortably maintain the complete Founda **Validated:** The isolated Compose stack published four Foundations lessons and one course through separate development identities, rendered four governed lessons on desktop and 390px mobile, and rebuilt reproducibly from a new Mongo volume. +**Post-completion closure:** Blank lesson/course creation, explicit next-revision actions, versioned course records, and pinned published lesson references close gaps found by the roadmap audit. Existing unversioned course records migrate to version 1 without deleting content. A clean staging run verified author/reviewer/publisher separation, lesson v1 pinning after lesson v2 publication, course v2 publication, cache headers, mobile layout, and clean-volume reproducibility. + ### Milestone 5 Retrospective - **Accomplished:** structured course and lesson authoring, immutable revisions, role-separated review/publishing, cacheable public delivery, bundled recovery content, and isolated contributor staging. - **Key decision:** the public catalogue includes a course only when the course and every ordered lesson are published; partial migrations remain invisible. - **Learned:** real content exposed a client/server media-validation mismatch that fixture-only tests missed. Publishing validation must exercise both schema boundaries. -- **Technical debt:** course revisions currently use one immutable published record per course id; a future content-operations slice should add explicit course-version lineage before multiple published course revisions are needed. +- **Technical debt:** the one-time course index migration must run before production creates a second course revision; deployment documentation includes the exact command. - **Next:** Milestone 6 should establish reviewed audio, video, illustration, caption, transcript, localization, and replacement workflows before expanding media-heavy lessons. ### Milestone 6 - Educational Media Pipeline -**Status:** ⏳ Planned +**Status:** 🚧 In Progress **Goal:** Produce and maintain reviewed, accessible educational assets without treating media as an afterthought. **Complexity:** High **Dependencies:** Milestone 5 @@ -427,12 +431,24 @@ Milestone 5 ends when a contributor can comfortably maintain the complete Founda - [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. -### Milestone 7 - Transparent Coach +### Milestone 7 - Remaining Foundations Lessons + +**Status:** ⏳ Planned +**Goal:** Complete Foundations using the proven engine, learner-history insights, structured authoring workflow, and reviewed media pipeline. +**Complexity:** High +**Dependencies:** Milestones 4-6 -**Status:** ⏳ Planned +**Acceptance criteria** +- [ ] Remaining approved Foundations lessons meet the same safety, evidence, accessibility, and content-review standard as the MVP slice. +- [ ] Lessons use real instructional media only when captions, transcripts, and review assets are ready. +- [ ] Learner history remains optional, supportive, and useful with the expanded course. + +### Milestone 8 - Transparent Coach + +**Status:** ⏳ Planned **Goal:** Recommend a kind next existing lesson or practice opportunity with visible, overrideable rules. **Complexity:** Medium -**Dependencies:** Milestones 4-6 +**Dependencies:** Milestones 4-7 **Acceptance criteria** - [ ] User-controlled time, path, confidence/ease, and recent-practice inputs. @@ -440,23 +456,19 @@ Milestone 5 ends when a contributor can comfortably maintain the complete Founda - [ ] Review, recovery, low-energy, and plateau paths guide users to existing content. - [ ] No opaque AI, gender scoring, diagnosis, or punishment mechanics. -### Milestone 8 - Remaining Foundations Lessons +### Backlog - Interactive Practice **Status:** ⏳ Planned -**Goal:** Complete Foundations using the proven engine, learner-history insights, and structured authoring workflow. -**Complexity:** High -**Dependencies:** Milestones 4-6 +**Goal:** Add a generic, privacy-first practice engine for guided voice activities after the educational media and content foundations are stable. -**Acceptance criteria** -- [ ] Remaining approved Foundations lessons meet the same safety, evidence, accessibility, and content-review standard as the MVP slice. -- [ ] Lessons use real instructional media only when captions, transcripts, and review assets are ready. -- [ ] Learner history and coach recommendations remain optional, supportive, and useful with the expanded course. +The existing `interactive_exercise` block remains suitable for simple learner-led activities. A future `interactive_practice` contract should support reusable practice types, local analysis providers, accessible non-microphone routes, and supportive observations without pass/fail scoring. Microphone analysis, pitch exploration, and recording playback are intentionally outside Milestone 6. ## Technical Debt And Risks - `src/App.jsx` owns much of the current UI and practice orchestration. New academy code must stay outside it; extracting existing practice state requires dedicated tests before it moves. - Existing cloud sync stores a version-1 progress blob. Academy data needs separate collections and APIs rather than silently changing that contract. - 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. - 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. @@ -517,3 +529,4 @@ Milestone 5 ends when a contributor can comfortably maintain the complete Founda - **2026-07-17:** The public Academy now prefers a cacheable, read-only published-content API. It excludes drafts, incomplete course paths, and unpublished lessons, and immediately falls back to bundled content when no complete published catalogue is available. - **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. diff --git a/docs/staging-academy.md b/docs/staging-academy.md index 3d6055a..9279e05 100644 --- a/docs/staging-academy.md +++ b/docs/staging-academy.md @@ -8,4 +8,6 @@ Use the Admin Academy to load Foundations references, save every lesson and cour The seed script refuses to run unless `FEMMEVOICE_ENV=staging`; Compose uses a separate Mongo volume and database and must never be pointed at production. +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. diff --git a/package-lock.json b/package-lock.json index 7e9ea1c..c7b8bad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "femmevoice", - "version": "0.3.29", + "version": "0.3.30", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "femmevoice", - "version": "0.3.29", + "version": "0.3.30", "dependencies": { "@vitejs/plugin-react": "^5.0.0", "lucide-react": "^0.468.0", diff --git a/package.json b/package.json index d8541a4..91c64cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "femmevoice", - "version": "0.3.29", + "version": "0.3.30", "private": true, "type": "module", "scripts": { diff --git a/server/academy_content.py b/server/academy_content.py index 1e64592..5d521c8 100644 --- a/server/academy_content.py +++ b/server/academy_content.py @@ -54,17 +54,60 @@ def review_result_status(decision): def build_public_catalogue(course_records, lesson_records): - lessons_by_slug = {item["lesson"]["slug"]: item["lesson"] for item in lesson_records if item.get("lesson", {}).get("slug")} - courses = [] + lesson_records_by_ref = {} + latest_lessons_by_slug = {} + for item in lesson_records: + lesson = item.get("lesson") or {} + slug = lesson.get("slug") + version = lesson.get("version", item.get("version", 1)) + if not slug or not isinstance(version, int): + continue + lesson_records_by_ref[(slug, version)] = lesson + if version > latest_lessons_by_slug.get(slug, {}).get("version", 0): + latest_lessons_by_slug[slug] = lesson + + latest_courses_by_slug = {} for record in course_records: course = record.get("course") or {} - ordered_lessons = [lessons_by_slug.get(slug) for slug in course.get("lessonIds", [])] + slug = course.get("slug") + version = course.get("version", record.get("version", 1)) + if slug and isinstance(version, int) and version > latest_courses_by_slug.get(slug, {}).get("version", 0): + latest_courses_by_slug[slug] = {**record, "version": version} + + courses = [] + for record in latest_courses_by_slug.values(): + course = record.get("course") or {} + references = record.get("published_lesson_refs") + if references: + ordered_lessons = [lesson_records_by_ref.get((reference.get("slug"), reference.get("version"))) for reference in references] + else: + # Legacy version-1 course records predate pinned lesson references. + ordered_lessons = [latest_lessons_by_slug.get(slug) for slug in course.get("lessonIds", [])] if not ordered_lessons or any(item is None for item in ordered_lessons): continue courses.append({"course": course, "lessons": ordered_lessons, "publishedAt": record.get("published_at")}) return {"schemaVersion": 1, "courses": courses} +def resolve_published_lesson_refs(course, lesson_records): + latest_by_slug = {} + for record in lesson_records: + lesson = record.get("lesson") or {} + slug = lesson.get("slug") + version = lesson.get("version", record.get("version", 1)) + if slug and isinstance(version, int) and version > latest_by_slug.get(slug, 0): + latest_by_slug[slug] = version + references = [] + for slug in course.get("lessonIds", []): + version = latest_by_slug.get(slug) + if not version: + raise ValueError(f"Publish lesson {slug} before publishing this course revision.") + references.append({"slug": slug, "version": version}) + if not references: + raise ValueError("A published course needs at least one published lesson.") + return references + + def validate_course_document(course): if not isinstance(course, dict): raise ValueError("Course must be an object.") @@ -75,9 +118,13 @@ def validate_course_document(course): raise ValueError("Course identity is invalid.") if not isinstance(course["estimatedMinutes"], int) or course["estimatedMinutes"] < 0: raise ValueError("Course duration is invalid.") + version = course.get("version", 1) + if not isinstance(version, int) or version < 1: + raise ValueError("Course version must be a positive integer.") if not isinstance(course["lessonIds"], list) or len(course["lessonIds"]) > 100 or len(set(course["lessonIds"])) != len(course["lessonIds"]) or not all(_text(item, 120) for item in course["lessonIds"]): raise ValueError("Course lesson ordering is invalid.") clean = deepcopy(course) + clean["version"] = version clean["prerequisiteCourseIds"] = [item for item in course.get("prerequisiteCourseIds", []) if _text(item, 120)] if isinstance(course.get("prerequisiteCourseIds", []), list) else [] clean["tags"] = [item for item in course.get("tags", []) if _text(item, 80)] if isinstance(course.get("tags", []), list) else [] return clean diff --git a/server/app.py b/server/app.py index 2ebef25..a024466 100644 --- a/server/app.py +++ b/server/app.py @@ -20,7 +20,7 @@ from werkzeug.middleware.proxy_fix import ProxyFix 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, review_result_status, validate_course_document, validate_lesson_document, validate_review +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 reminder_logic import VALID_REMINDER_TONES, normalize_reminder_days ROOT = Path(__file__).resolve().parent.parent @@ -64,9 +64,10 @@ app = Flask(__name__, static_folder=str(DIST), static_url_path="") app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1) app.secret_key = os.environ.get("SECRET_KEY", secrets.token_hex(32)) +session_cookie_secure = os.environ.get("SESSION_COOKIE_SECURE", "true").strip().lower() not in {"0", "false", "no"} app.config.update( SESSION_COOKIE_NAME="femmevoice_session", - SESSION_COOKIE_SECURE=True, + SESSION_COOKIE_SECURE=session_cookie_secure, SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax", PERMANENT_SESSION_LIFETIME=timedelta(days=14), @@ -94,7 +95,7 @@ academy_history_collection.create_index([("user_id", ASCENDING)], unique=True) 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)], unique=True) +academy_courses_collection.create_index([("course_id", ASCENDING), ("version", ASCENDING)], unique=True) @app.after_request @@ -359,11 +360,11 @@ def register(): except DuplicateKeyError: return auth_error("That username is unavailable.", 409) legacy_username = session.get("migration_username") - account_user = {"id": str(result.inserted_id), "username": username, "display_name": username, "is_admin": normalized in ADMIN_USERNAMES} session.clear() session["user_id"] = str(result.inserted_id) session.permanent = True csrf_token() + account_user = user_from_request() migrated = migrate_progress(legacy_username, f"account:{result.inserted_id}", account_user) if legacy_username else False return jsonify({"authenticated": True, "user": account_user, "migrated": migrated}), 201 @@ -386,7 +387,7 @@ def login(): session.permanent = True csrf_token() users_collection.update_one({"_id": user["_id"]}, {"$set": {"last_login_at": now_iso()}}) - return jsonify({"authenticated": True, "user": {"id": str(user["_id"]), "username": user["username"], "display_name": user.get("display_name") or user["username"], "is_admin": user["username_normalized"] in ADMIN_USERNAMES}}) + return jsonify({"authenticated": True, "user": user_from_request()}) @app.post("/api/auth/logout") @@ -544,8 +545,8 @@ def delete_personal_data(): @app.get("/api/academy/content") def public_academy_content(): """Expose only complete, published course revisions to anonymous learners.""" - lessons = list(academy_lessons_collection.find({"status": "published"}, {"_id": 0, "lesson": 1, "updated_at": 1})) - courses = list(academy_courses_collection.find({"status": "published"}, {"_id": 0, "course": 1, "updated_at": 1}).sort("updated_at", -1).limit(100)) + lessons = list(academy_lessons_collection.find({"status": "published"}, {"_id": 0, "lesson": 1, "version": 1, "updated_at": 1})) + courses = list(academy_courses_collection.find({"status": "published"}, {"_id": 0, "course": 1, "version": 1, "published_lesson_refs": 1, "published_at": 1, "updated_at": 1}).sort("updated_at", -1).limit(100)) response = jsonify(build_public_catalogue(courses, lessons)) response.headers["Cache-Control"] = "public, max-age=60, stale-while-revalidate=300" return response @@ -569,11 +570,15 @@ def list_academy_courses_for_admin(): if not user or not user["academy_roles"]: return auth_error("Academy authoring access is required.", 403) records = list(academy_courses_collection.find({}, {"_id": 0}).sort("updated_at", -1).limit(100)) + for record in records: + record["version"] = record.get("version", record.get("course", {}).get("version", 1)) + if isinstance(record.get("course"), dict): + record["course"]["version"] = record["version"] return jsonify({"courses": records}) -@app.put("/api/admin/academy/courses/") -def save_academy_course_draft(course_id): +@app.put("/api/admin/academy/courses//") +def save_academy_course_draft(course_id, version): if not csrf_required(): return auth_error("Your session expired. Refresh and try again.", 403) user = academy_user_with_role("author") @@ -583,21 +588,24 @@ def save_academy_course_draft(course_id): course = validate_course_document((request.get_json(silent=True) or {}).get("course")) except ValueError as error: return auth_error(str(error)) - if course["id"] != course_id: - return auth_error("Course path must match the course id.") - existing = academy_courses_collection.find_one({"course_id": course_id}) + if course["id"] != course_id or course["version"] != version: + return auth_error("Course path must match the course id and version.") + existing = academy_courses_collection.find_one({"course_id": course_id, "version": version}) if existing and existing.get("status") == "published": return auth_error("Published courses are immutable. Create a new course revision before changing it.", 409) timestamp = now_iso() - academy_courses_collection.update_one({"course_id": course_id}, {"$set": {"course_id": course_id, "course": course, "status": "draft", "updated_at": timestamp, "authored_by": user["username"]}, "$setOnInsert": {"created_at": timestamp}}, upsert=True) + try: + academy_courses_collection.update_one({"course_id": course_id, "version": version}, {"$set": {"course_id": course_id, "version": version, "course": course, "status": "draft", "updated_at": timestamp, "authored_by": user["username"]}, "$setOnInsert": {"created_at": timestamp}}, upsert=True) + except DuplicateKeyError: + return auth_error("Course revision storage needs the documented version-index migration before another version can be saved.", 409) return jsonify({"ok": True, "status": "draft", "updated_at": timestamp}) -@app.put("/api/admin/academy/courses//submit-review") -def submit_academy_course_for_review(course_id): +@app.put("/api/admin/academy/courses///submit-review") +def submit_academy_course_for_review(course_id, version): if not csrf_required(): return auth_error("Your session expired. Refresh and try again.", 403) user = academy_user_with_role("author") - record = academy_courses_collection.find_one({"course_id": course_id}) + record = academy_courses_collection.find_one({"course_id": course_id, "version": version}) if not user: return auth_error("Academy author access is required.", 403) if not record: return auth_error("Save the course draft before requesting review.", 404) if not can_submit_for_review(record.get("status")): return auth_error("Only a draft course can be submitted for review.", 409) @@ -605,11 +613,11 @@ def submit_academy_course_for_review(course_id): return jsonify({"ok": True, "status": "review_requested"}) -@app.put("/api/admin/academy/courses//review") -def review_academy_course(course_id): +@app.put("/api/admin/academy/courses///review") +def review_academy_course(course_id, version): if not csrf_required(): return auth_error("Your session expired. Refresh and try again.", 403) user = academy_user_with_role("reviewer") - record = academy_courses_collection.find_one({"course_id": course_id}) + record = academy_courses_collection.find_one({"course_id": course_id, "version": version}) if not user: return auth_error("Academy reviewer access is required.", 403) if not record: return auth_error("Academy course was not found.", 404) if record.get("status") != "review_requested": return auth_error("An author must submit this course for review first.", 409) @@ -621,15 +629,19 @@ def review_academy_course(course_id): return jsonify({"ok": True, "status": status, "review": review}) -@app.put("/api/admin/academy/courses//publish") -def publish_academy_course(course_id): +@app.put("/api/admin/academy/courses///publish") +def publish_academy_course(course_id, version): if not csrf_required(): return auth_error("Your session expired. Refresh and try again.", 403) user = academy_user_with_role("publisher") - record = academy_courses_collection.find_one({"course_id": course_id}) + record = academy_courses_collection.find_one({"course_id": course_id, "version": version}) if not user: return auth_error("Academy publisher access is required.", 403) if not record: return auth_error("Academy course was not found.", 404) if record.get("status") != "in_review" or record.get("review", {}).get("decision") != "approved": return auth_error("An approved course review is required before publishing.", 409) - academy_courses_collection.update_one({"_id": record["_id"]}, {"$set": {"status": "published", "published_at": now_iso(), "published_by": user["username"], "updated_at": now_iso()}}) + try: + lesson_refs = resolve_published_lesson_refs(record["course"], academy_lessons_collection.find({"status": "published"}, {"_id": 0, "lesson": 1, "version": 1})) + except ValueError as error: + return auth_error(str(error), 409) + academy_courses_collection.update_one({"_id": record["_id"]}, {"$set": {"status": "published", "published_lesson_refs": lesson_refs, "published_at": now_iso(), "published_by": user["username"], "updated_at": now_iso()}}) return jsonify({"ok": True, "status": "published"}) diff --git a/server/migrate_academy_course_versions.py b/server/migrate_academy_course_versions.py new file mode 100644 index 0000000..400d9b2 --- /dev/null +++ b/server/migrate_academy_course_versions.py @@ -0,0 +1,28 @@ +"""Add version identity to legacy Academy course records without deleting content.""" +import os + + +def migrate_course_versions(database): + courses = database["academy_courses"] + result = courses.update_many({"version": {"$exists": False}}, {"$set": {"version": 1, "course.version": 1}}) + for name, definition in courses.index_information().items(): + if definition.get("key") == [("course_id", 1)] and definition.get("unique"): + try: + courses.drop_index(name) + except Exception as error: + if getattr(error, "code", None) != 27: + raise + courses.create_index([("course_id", 1), ("version", 1)], unique=True) + return result.modified_count + + +if __name__ == "__main__": + if os.environ.get("FEMMEVOICE_CONFIRM_MIGRATION") != "academy-course-versions-v1": + raise SystemExit("Refusing to migrate: set FEMMEVOICE_CONFIRM_MIGRATION=academy-course-versions-v1.") + if not os.environ.get("MONGO_URI") or not os.environ.get("MONGO_DB"): + raise SystemExit("MONGO_URI and MONGO_DB are required.") + from pymongo import MongoClient + + db = MongoClient(os.environ["MONGO_URI"])[os.environ["MONGO_DB"]] + changed = migrate_course_versions(db) + print(f"Academy course version migration complete: {changed} legacy record(s) updated.") diff --git a/server/test_academy_content.py b/server/test_academy_content.py index d2eed12..37be931 100644 --- a/server/test_academy_content.py +++ b/server/test_academy_content.py @@ -1,6 +1,6 @@ import unittest -from academy_content import build_public_catalogue, can_submit_for_review, review_result_status, validate_course_document, validate_lesson_document, validate_review +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 def lesson(): @@ -33,9 +33,39 @@ def test_course_keeps_an_ordered_unique_lesson_path(self): with self.assertRaises(ValueError): validate_course_document(course) + def test_course_versions_default_legacy_content_to_version_one(self): + course = {"id": "foundations", "slug": "foundations", "title": "Foundations", "summary": "A calm start.", "locale": "en", "estimatedMinutes": 60, "lessonIds": ["welcome"]} + self.assertEqual(validate_course_document(course)["version"], 1) + course["version"] = 0 + with self.assertRaises(ValueError): + validate_course_document(course) + def test_public_catalogue_excludes_courses_with_unpublished_lesson_gaps(self): course = {"course": {"slug": "foundations", "lessonIds": ["welcome", "safety"]}, "published_at": "2026-07-17T00:00:00Z"} lessons = [{"lesson": {"slug": "welcome", "title": "Welcome"}}] self.assertEqual(build_public_catalogue([course], lessons)["courses"], []) lessons.append({"lesson": {"slug": "safety", "title": "Safety"}}) self.assertEqual(build_public_catalogue([course], lessons)["courses"][0]["lessons"][1]["title"], "Safety") + + def test_published_course_pins_exact_lesson_revisions(self): + course = {"id": "foundations", "slug": "foundations", "version": 2, "lessonIds": ["welcome"]} + lessons = [ + {"lesson": {"id": "welcome", "slug": "welcome", "title": "Welcome v1", "version": 1}}, + {"lesson": {"id": "welcome", "slug": "welcome", "title": "Welcome v2", "version": 2}}, + ] + references = resolve_published_lesson_refs(course, lessons) + self.assertEqual(references, [{"slug": "welcome", "version": 2}]) + record = {"course": course, "version": 2, "published_lesson_refs": references} + self.assertEqual(build_public_catalogue([record], lessons)["courses"][0]["lessons"][0]["title"], "Welcome v2") + lessons.append({"lesson": {"id": "welcome", "slug": "welcome", "title": "Welcome v3", "version": 3}}) + self.assertEqual(build_public_catalogue([record], lessons)["courses"][0]["lessons"][0]["title"], "Welcome v2") + + def test_public_catalogue_uses_only_latest_published_course_revision(self): + lesson_records = [{"lesson": {"slug": "welcome", "title": "Welcome", "version": 1}}] + courses = [ + {"course": {"slug": "foundations", "version": 1, "lessonIds": ["welcome"]}, "version": 1}, + {"course": {"slug": "foundations", "version": 2, "title": "Updated", "lessonIds": ["welcome"]}, "version": 2}, + ] + catalogue = build_public_catalogue(courses, lesson_records) + self.assertEqual(len(catalogue["courses"]), 1) + self.assertEqual(catalogue["courses"][0]["course"]["title"], "Updated") diff --git a/server/test_academy_course_migration.py b/server/test_academy_course_migration.py new file mode 100644 index 0000000..4fc28c4 --- /dev/null +++ b/server/test_academy_course_migration.py @@ -0,0 +1,39 @@ +import unittest + +from migrate_academy_course_versions import migrate_course_versions + + +class Result: + modified_count = 2 + + +class FakeCourses: + def __init__(self): + self.dropped = [] + self.created = [] + + def update_many(self, query, update): + self.update = (query, update) + return Result() + + def index_information(self): + return { + "_id_": {"key": [("_id", 1)]}, + "course_id_1": {"key": [("course_id", 1)], "unique": True}, + } + + def drop_index(self, name): + self.dropped.append(name) + + def create_index(self, keys, unique=False): + self.created.append((keys, unique)) + + +class AcademyCourseMigrationTests(unittest.TestCase): + def test_migration_preserves_records_and_replaces_only_legacy_identity_index(self): + courses = FakeCourses() + changed = migrate_course_versions({"academy_courses": courses}) + self.assertEqual(changed, 2) + self.assertEqual(courses.dropped, ["course_id_1"]) + self.assertEqual(courses.created, [([("course_id", 1), ("version", 1)], True)]) + self.assertEqual(courses.update[1]["$set"], {"version": 1, "course.version": 1}) diff --git a/src/academy/AdminAcademy.jsx b/src/academy/AdminAcademy.jsx index d4f1093..337bcfe 100644 --- a/src/academy/AdminAcademy.jsx +++ b/src/academy/AdminAcademy.jsx @@ -1,11 +1,12 @@ import { useEffect, useMemo, useState } from "react"; -import { BookOpen, ChevronDown, ChevronUp, Eye, FilePlus2, Save, Send, ShieldCheck, Stamp, Trash2 } from "lucide-react"; +import { BookOpen, ChevronDown, ChevronUp, CopyPlus, Eye, FilePlus2, Save, Send, ShieldCheck, Stamp, Trash2 } from "lucide-react"; import { listAcademyAdminCourses, listAcademyAdminLessons, loadAcademyAdminLesson, publishAcademyAdminCourse, publishAcademyAdminLesson, reviewAcademyAdminCourse, reviewAcademyAdminLesson, saveAcademyAdminCourse, saveAcademyAdminLesson, submitAcademyAdminCourseForReview, submitAcademyAdminLessonForReview } from "../api"; import { ACADEMY_COURSES } from "./catalog"; import { FOUNDATIONS_LESSONS } from "./content/foundations"; import LessonPlayer from "./LessonPlayer"; import { validateLesson } from "./schema"; import { BLOCK_TYPES, getBlockDefinition } from "./blockRegistry"; +import { createBlankCourse, createBlankLesson, createNextCourseRevision, createNextLessonRevision } from "./authoringTemplates"; const completionKinds = ["manual", "optional", "response", "quiz", "activity"]; @@ -52,6 +53,28 @@ export default function AdminAcademy({ roles }) { setPreview(false); } + function startBlankLesson() { + const draft = createBlankLesson(); + setLessonDraft(draft, "New lesson created locally. Give it a clear title and stable ID before saving."); + setChangeNote("Created a new lesson."); + resetLessonWorkflow(); + } + + function startNextLessonRevision() { + if (!lesson) return; + const draft = createNextLessonRevision(lesson); + setLessonDraft(draft, `Version ${draft.version} created as a private draft. The published revision is unchanged.`); + setChangeNote(`Started from published version ${lesson.version}.`); + resetLessonWorkflow(); + } + + function resetLessonWorkflow() { + setLessonStatus("draft"); + setReview({ decision: "approved", content_checked: false, research_checked: false, accessibility_checked: false, note: "" }); + setComparison(null); + setPreview(false); + } + async function openRecord(record) { try { const payload = await loadAcademyAdminLesson(record.lesson_id, record.version); @@ -122,6 +145,7 @@ export default function AdminAcademy({ roles }) { title: reference.title, summary: reference.summary, locale: "en", + version: 1, estimatedMinutes: reference.estimatedMinutes, lessonIds: reference.lessons.map((item) => item.slug), tags: ["foundations", "transfeminine"], @@ -130,6 +154,23 @@ export default function AdminAcademy({ roles }) { setCourseStatus("draft"); setCourseReview({ decision: "approved", content_checked: false, research_checked: false, accessibility_checked: false, note: "" }); } + function startBlankCourse() { + setCourseDraft(createBlankCourse(), "New course created locally. Add lessons when their drafts are ready."); + resetCourseWorkflow(); + } + + function startNextCourseRevision() { + if (!course) return; + const draft = createNextCourseRevision(course); + setCourseDraft(draft, `Course version ${draft.version} created as a private draft.`); + resetCourseWorkflow(); + } + + function resetCourseWorkflow() { + setCourseStatus("draft"); + setCourseReview({ decision: "approved", content_checked: false, research_checked: false, accessibility_checked: false, note: "" }); + } + function openCourse(record) { setCourseDraft(structuredClone(record.course), `Editing ${record.course.title}.`); setCourseStatus(record.status ?? "draft"); setCourseReview(record.review ?? { decision: "approved", content_checked: false, research_checked: false, accessibility_checked: false, note: "" }); @@ -146,7 +187,7 @@ export default function AdminAcademy({ roles }) { if (!course || !roles.includes("author")) return; try { setStatus("Saving course draft..."); - await saveAcademyAdminCourse(course.id, course); + await saveAcademyAdminCourse(course.id, course.version ?? 1, course); setCourseStatus("draft"); setStatus("Course draft saved with its lesson order."); refresh(); @@ -156,7 +197,8 @@ export default function AdminAcademy({ roles }) { async function courseTransition(action) { if (!course) return; try { - const result = action === "submit" ? await submitAcademyAdminCourseForReview(course.id) : action === "review" ? await reviewAcademyAdminCourse(course.id, courseReview) : await publishAcademyAdminCourse(course.id); + const version = course.version ?? 1; + const result = action === "submit" ? await submitAcademyAdminCourseForReview(course.id, version) : action === "review" ? await reviewAcademyAdminCourse(course.id, version, courseReview) : await publishAcademyAdminCourse(course.id, version); setCourseStatus(result.status); if (result.review) setCourseReview(result.review); setStatus(action === "publish" ? "Course published. Learners can receive it only with its complete published lesson path." : "Course workflow updated."); refresh(); } catch (error) { setStatus(error.message); } } @@ -188,14 +230,14 @@ export default function AdminAcademy({ roles }) { return
- +
- {!lesson ? startFoundationsReference()} /> : <> + {!lesson ? startFoundationsReference()} /> : <> - +
Advanced structured document

Use this only for carefully reviewed schema-level changes. Normal lesson authoring happens in the forms above.