Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/academy-content-delivery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Governed Academy Content Delivery

`GET /api/academy/content` is the learner-facing read-only catalogue. It is cacheable and returns only courses marked `published` whose ordered lesson path is complete and consists entirely of published lesson revisions. It never returns drafts, review requests, reviewer notes, or author identities.

The React Academy renders bundled versioned content immediately, then replaces it only when this endpoint returns a complete compatible catalogue. This keeps local development, offline recovery, and a temporary content-store outage safe without making static source the primary path after publication.

Courses and lessons follow the same author, reviewer, publisher separation. An author saves and submits a draft, a reviewer completes content, research, and accessibility checks, and a publisher releases the immutable record. A course cannot be publicly visible until every lesson it orders is published.

The final Foundations migration must be performed through those identities in the Admin Academy. Automation must not silently mark educational content reviewed or published.
9 changes: 6 additions & 3 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@

| Item | Current state |
| --- | --- |
| Current milestone | Milestone 5H - Revision comparison and Foundations migration, in progress |
| Current version | `v0.3.25` |
| Current working branch | `feat/academy-revision-comparison` |
| Current milestone | Milestone 5H - Governed content delivery and Foundations migration, in progress |
| Current version | `v0.3.26` |
| Current working branch | `feat/academy-content-delivery` |
| Active pull request(s) | Revision-comparison PR in preparation. PR #14 is integrated into `main`. |
| Base branch | `main` is canonical. |
| Next planned milestone | Full Foundations migration through Admin Academy, then Milestone 5 review |
Expand Down Expand Up @@ -49,6 +49,7 @@ The definitive product direction is the [Product Vision](product-vision.md). Voi
- `v0.3.23` - Structured course authoring and ordering forms
- `v0.3.24` - Academy review and publishing workflow
- `v0.3.25` - Academy revision comparison
- `v0.3.26` - Governed Academy content delivery

Update this list whenever a versioned change is pushed so milestones, pull requests, and releases remain easy to correlate.

Expand Down Expand Up @@ -399,6 +400,7 @@ Milestone 5 ends when a contributor can comfortably maintain the complete Founda
**Remaining**
- [ ] Use the complete draft/review/publish flow to migrate and maintain the full Foundations course.
- [ ] Conduct the Milestone 5 practical authoring review against that real content.
- [ ] Complete the live author, reviewer, and publisher approvals for Foundations; this is intentionally not bypassed by a deployment script.

### Milestone 6 - Educational Media Pipeline

Expand Down Expand Up @@ -502,3 +504,4 @@ Milestone 5 ends when a contributor can comfortably maintain the complete Founda
- **2026-07-17:** Milestone 5F replaces the remaining course JSON route with structured metadata and accessible ordering controls. The editor uses the existing course validation contract and starts from the real Foundations path.
- **2026-07-17:** Milestone 5G adds a guarded author-submit, reviewer-check, publisher-release workflow. Server-side transitions reject skipped review and preserve published-revision immutability.
- **2026-07-17:** Milestone 5H adds a readable revision-comparison view rather than relying on authors to infer changes from version numbers. The remaining proof is real Foundations migration through the completed workflow.
- **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.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "femmevoice",
"version": "0.3.25",
"version": "0.3.26",
"private": true,
"type": "module",
"scripts": {
Expand Down
12 changes: 12 additions & 0 deletions server/academy_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ def review_result_status(decision):
return "in_review" if decision == "approved" else "draft"


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 = []
for record in course_records:
course = record.get("course") or {}
ordered_lessons = [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 validate_course_document(course):
if not isinstance(course, dict):
raise ValueError("Course must be an object.")
Expand Down
57 changes: 55 additions & 2 deletions server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 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, 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
Expand Down Expand Up @@ -112,7 +112,7 @@ 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/") or request.path.startswith("/api/account/academy-history"):
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"):
response.headers["Cache-Control"] = "no-store"
return response

Expand Down Expand Up @@ -541,6 +541,16 @@ def delete_personal_data():
return response


@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))
response = jsonify(build_public_catalogue(courses, lessons))
response.headers["Cache-Control"] = "public, max-age=60, stale-while-revalidate=300"
return response


@app.get("/api/admin/academy/lessons")
def list_academy_lessons_for_admin():
user = user_from_request()
Expand Down Expand Up @@ -575,11 +585,54 @@ def save_academy_course_draft(course_id):
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 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)
return jsonify({"ok": True, "status": "draft", "updated_at": timestamp})


@app.put("/api/admin/academy/courses/<course_id>/submit-review")
def submit_academy_course_for_review(course_id):
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})
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)
academy_courses_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/courses/<course_id>/review")
def review_academy_course(course_id):
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})
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)
try: review = validate_review((request.get_json(silent=True) or {}).get("review"))
except ValueError as error: return auth_error(str(error))
review.update({"reviewed_by": user["username"], "reviewed_at": now_iso()})
status = review_result_status(review["decision"])
academy_courses_collection.update_one({"_id": record["_id"]}, {"$set": {"status": status, "review": review, "updated_at": now_iso()}})
return jsonify({"ok": True, "status": status, "review": review})


@app.put("/api/admin/academy/courses/<course_id>/publish")
def publish_academy_course(course_id):
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})
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()}})
return jsonify({"ok": True, "status": "published"})


@app.get("/api/admin/academy/lessons/<lesson_id>/<int:version>")
def get_academy_lesson_for_admin(lesson_id, version):
user = user_from_request()
Expand Down
9 changes: 8 additions & 1 deletion server/test_academy_content.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import unittest

from academy_content import 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, review_result_status, validate_course_document, validate_lesson_document, validate_review


def lesson():
Expand Down Expand Up @@ -32,3 +32,10 @@ def test_course_keeps_an_ordered_unique_lesson_path(self):
course["lessonIds"].append("welcome")
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")
20 changes: 16 additions & 4 deletions src/academy/AcademyView.jsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
import { useEffect, useState } from "react";
import { ArrowLeft, ArrowRight, BookOpen, CheckCircle2, Clock3, ShieldCheck } from "lucide-react";
import { ACADEMY_COURSES, formatCourseDuration, getAcademyCourse } from "./catalog";
import { formatCourseDuration } from "./catalog";
import { loadPublicAcademyContent } from "../api";
import LessonPlayer from "./LessonPlayer";
import { getFoundationsLesson } from "./content/foundations";
import AcademyHistory from "./AcademyHistory";
import { normalizePublishedAcademyContent, staticAcademyContent } from "./publicContent";

export default function AcademyView({ courseSlug, lessonSlug, onOpenCourse, onBack, history, historySyncEnabled, onLessonHistory, onDeleteHistory, onAddJournal }) {
const course = courseSlug ? getAcademyCourse(courseSlug) : null;
const lesson = courseSlug === "foundations" && lessonSlug ? getFoundationsLesson(lessonSlug) : null;
const [content, setContent] = useState(staticAcademyContent);
const course = courseSlug ? content.courses.find((item) => item.slug === courseSlug) ?? null : null;
const lesson = lessonSlug ? course?.lessonDocuments?.[lessonSlug] ?? course?.lessons.find((item) => item.slug === lessonSlug)?.document ?? (courseSlug === "foundations" ? getFoundationsLesson(lessonSlug) : null) : null;

useEffect(() => {
let active = true;
loadPublicAcademyContent().then((payload) => {
if (active) setContent(normalizePublishedAcademyContent(payload));
}).catch(() => {});
return () => { active = false; };
}, []);

if (course && lesson) {
return <LessonPlayer key={`${lesson.id}:${lesson.version}`} lesson={lesson} courseTitle={course.title} courseSlug={course.slug} onHistoryEvent={onLessonHistory} onExit={() => onOpenCourse(course.slug)} />;
Expand Down Expand Up @@ -47,7 +59,7 @@ export default function AcademyView({ courseSlug, lessonSlug, onOpenCourse, onBa
</section>

<section className="academy-catalogue" aria-label="Academy courses">
{ACADEMY_COURSES.map((item) => (
{content.courses.map((item) => (
<article className={`academy-course academy-course-${item.status}`} key={item.slug}>
<div className="academy-course-topline"><p className="eyebrow">{item.eyebrow}</p><span>{item.status === "available" ? "Ready" : "Planned"}</span></div>
<h3>{item.title}</h3>
Expand Down
Loading
Loading