World context
+diff --git a/backend/alembic/versions/f2b7c9d1e4a6_normalize_progress_status_enum_values.py b/backend/alembic/versions/f2b7c9d1e4a6_normalize_progress_status_enum_values.py
new file mode 100644
index 0000000..94f2e41
--- /dev/null
+++ b/backend/alembic/versions/f2b7c9d1e4a6_normalize_progress_status_enum_values.py
@@ -0,0 +1,76 @@
+"""Normalize progress status enum values
+
+Revision ID: f2b7c9d1e4a6
+Revises: d4e5f6a7b8c9
+Create Date: 2026-07-02 08:45:00.000000
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = "f2b7c9d1e4a6"
+down_revision = "d4e5f6a7b8c9"
+branch_labels = None
+depends_on = None
+
+
+def _rename_progress_status_values(pairs: list[tuple[str, str]]) -> None:
+ conn = op.get_bind()
+ for old_value, new_value in pairs:
+ conn.execute(
+ sa.text(
+ """
+ DO $$
+ BEGIN
+ IF EXISTS (
+ SELECT 1
+ FROM pg_enum e
+ JOIN pg_type t ON t.oid = e.enumtypid
+ WHERE t.typname = 'progress_status_enum'
+ AND e.enumlabel = :old_value
+ )
+ AND NOT EXISTS (
+ SELECT 1
+ FROM pg_enum e
+ JOIN pg_type t ON t.oid = e.enumtypid
+ WHERE t.typname = 'progress_status_enum'
+ AND e.enumlabel = :new_value
+ ) THEN
+ EXECUTE format(
+ 'ALTER TYPE progress_status_enum RENAME VALUE %L TO %L',
+ :old_value,
+ :new_value
+ );
+ END IF;
+ END $$;
+ """
+ ),
+ {"old_value": old_value, "new_value": new_value},
+ )
+
+
+def upgrade() -> None:
+ _rename_progress_status_values(
+ [
+ ("NOT_STARTED", "not_started"),
+ ("IN_PROGRESS", "in_progress"),
+ ("SKIPPED", "skipped"),
+ ("DELIVERED", "delivered"),
+ ("COMPLETED", "completed"),
+ ]
+ )
+
+
+def downgrade() -> None:
+ _rename_progress_status_values(
+ [
+ ("not_started", "NOT_STARTED"),
+ ("in_progress", "IN_PROGRESS"),
+ ("skipped", "SKIPPED"),
+ ("delivered", "DELIVERED"),
+ ("completed", "COMPLETED"),
+ ]
+ )
diff --git a/backend/app/seed_demo.py b/backend/app/seed_demo.py
index 24b8b27..7ab5cfa 100644
--- a/backend/app/seed_demo.py
+++ b/backend/app/seed_demo.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import json
from datetime import datetime, timedelta
from sqlalchemy import or_
@@ -40,6 +41,7 @@
StudentCourse,
StudentProgramProgress,
StudentUnitProgress,
+ StorybookPage,
Thread,
Unit,
User,
@@ -49,6 +51,66 @@
DEMO_PASSWORD = "password"
DEMO_ORG_NAME = "EchoEd Demo School"
DEMO_COURSE_TITLE = "Introduction to Africa"
+DEMO_STORYBOOK_BASE_URL = "http://127.0.0.1:8000/storybook"
+DEMO_COLORINGS_BASE_URL = "http://127.0.0.1:8000/colorings"
+DEMO_MAP_REFERENCE_URL = "/assets/maps/africa-reference.svg"
+DEMO_WORLD_CONTEXT_URL = "/assets/maps/world-context.svg"
+DEMO_STORYBOOK_PAGES = [
+ "08ef622b-2b8a-41f0-8e43-74f4ee11d216.png",
+ "1ca7b568-f7eb-4877-bd9e-22919ec90a36.png",
+ "268b14ab-3b76-4153-84ac-5238658d1f72.png",
+ "1d7d06af-ad31-4ac4-ba83-82a720079317.png",
+]
+DEMO_COLORING_PAGE = "4b4d875a-0f94-4408-9048-ab69d013d957.png"
+DEMO_AFRICA_MAP_EXPLORER = {
+ "title": "Africa Regions Explorer",
+ "prompt": "Tap each region to see a map-reading clue. Look for coastlines, deserts, rivers, highlands, and routes that connect people across the continent.",
+ "contextImageUrl": DEMO_WORLD_CONTEXT_URL,
+ "referenceImageUrl": DEMO_MAP_REFERENCE_URL,
+ "referenceImageAlt": "Africa reference map with five regions labeled",
+ "regions": [
+ {
+ "id": "north",
+ "label": "North Africa",
+ "fact": "North Africa sits near the Mediterranean coast and the Sahara. It connects African, Arab, and Mediterranean history.",
+ "focus": ["coastline", "desert edge", "trade routes"],
+ "x": 50,
+ "y": 23,
+ },
+ {
+ "id": "west",
+ "label": "West Africa",
+ "fact": "West Africa is a place of rivers, markets, languages, and strong trading traditions across many communities.",
+ "focus": ["river systems", "market routes", "languages"],
+ "x": 33,
+ "y": 53,
+ },
+ {
+ "id": "central",
+ "label": "Central Africa",
+ "fact": "Central Africa includes forested regions and river networks that help people travel, farm, and stay connected.",
+ "focus": ["forest", "river", "community"],
+ "x": 51,
+ "y": 53,
+ },
+ {
+ "id": "east",
+ "label": "East Africa",
+ "fact": "East Africa includes highlands, lakes, and routes that link inland communities with the coast and the wider region.",
+ "focus": ["highlands", "lakes", "routes"],
+ "x": 68,
+ "y": 50,
+ },
+ {
+ "id": "south",
+ "label": "Southern Africa",
+ "fact": "Southern Africa has coastlines, plains, and mountain regions where people have long built rich community life.",
+ "focus": ["coastline", "plains", "mountains"],
+ "x": 55,
+ "y": 83,
+ },
+ ],
+}
LEGACY_DEMO_COURSE_TITLES = {
DEMO_COURSE_TITLE,
"K-5 Introduction to Africa",
@@ -104,22 +166,53 @@
}
],
"activities": [
+ {
+ "type": "image",
+ "title": "Africa in the World",
+ "order": 1,
+ "content": DEMO_WORLD_CONTEXT_URL,
+ },
+ {
+ "type": "interactive_map",
+ "title": "Africa Regions Explorer",
+ "order": 2,
+ "content": json.dumps(DEMO_AFRICA_MAP_EXPLORER),
+ },
+ {
+ "type": "storybook",
+ "title": "Meet Children of Africa Storybook",
+ "order": 3,
+ "content": "Read the picture story first. Notice the children, places, and details that show Africa as full of many communities and everyday stories.",
+ "pages": [
+ {
+ "image_url": f"{DEMO_STORYBOOK_BASE_URL}/{filename}",
+ "order": index + 1,
+ }
+ for index, filename in enumerate(DEMO_STORYBOOK_PAGES)
+ ],
+ },
+ {
+ "type": "coloring",
+ "title": "Living Places Coloring Page",
+ "order": 4,
+ "content": f"{DEMO_COLORINGS_BASE_URL}/{DEMO_COLORING_PAGE}",
+ },
{
"type": "story",
"title": "Map Window",
- "order": 1,
+ "order": 5,
"content": "Imagine opening a giant classroom map and noticing that Africa stretches across many regions, climates, and communities. As you read, look for clues that show Africa is a continent full of many stories rather than a single place.",
},
{
"type": "quiz",
"title": "Key Concept Check",
- "order": 2,
+ "order": 6,
"content": "{\"question\":\"Which word best describes Africa?\",\"options\":[\"A single country\",\"A diverse continent\",\"Only a desert\"]}",
},
{
"type": "reflection",
"title": "Place and Perspective Reflection",
- "order": 3,
+ "order": 7,
"content": "Write or say one new idea you learned about Africa and one reason it matters to learn about places with care and accuracy.",
},
],
@@ -155,22 +248,41 @@
}
],
"activities": [
+ {
+ "type": "storybook",
+ "title": "Regions and Rivers Storybook",
+ "order": 1,
+ "content": "Read this storybook before the map work. Notice how children, homes, and places connect across Africa as communities move along rivers and routes.",
+ "pages": [
+ {
+ "image_url": f"{DEMO_STORYBOOK_BASE_URL}/{filename}",
+ "order": index + 1,
+ }
+ for index, filename in enumerate(DEMO_STORYBOOK_PAGES)
+ ],
+ },
+ {
+ "type": "coloring",
+ "title": "River Communities Coloring Page",
+ "order": 2,
+ "content": f"{DEMO_COLORINGS_BASE_URL}/{DEMO_COLORING_PAGE}",
+ },
{
"type": "story",
"title": "River Journey Prompt",
- "order": 1,
+ "order": 3,
"content": "Picture a family traveling beside a great river. What might they see, grow, carry, or trade along the way? Use the lesson clues to imagine the journey.",
},
{
"type": "checkpoint",
"title": "Geography Exploration Prompt",
- "order": 2,
+ "order": 4,
"content": "Point to one river or route on your class map and explain how it could help people move, gather food, or share ideas.",
},
{
"type": "reflection",
"title": "Community Connection",
- "order": 3,
+ "order": 5,
"content": "How do rivers, roads, or paths help communities in your world stay connected? Share one comparison with the lesson.",
},
],
@@ -1660,7 +1772,31 @@ def _sync_lesson_sources(db, lesson: Lesson, sources: list[dict[str, str]]) -> N
db.delete(stale_source)
-def _sync_lesson_activities(db, lesson: Lesson, activities: list[dict[str, str | int]]) -> None:
+def _sync_storybook_pages(db, activity: Activity, pages: list[dict[str, object]]) -> None:
+ existing_pages = sorted(
+ list(activity.storybook_pages or []),
+ key=lambda page: (
+ page.order is None,
+ page.order if page.order is not None else 0,
+ str(page.id),
+ ),
+ )
+
+ for index, page_seed in enumerate(pages):
+ if index < len(existing_pages):
+ page = existing_pages[index]
+ else:
+ page = StorybookPage(activity=activity)
+ db.add(page)
+
+ page.image_url = str(page_seed["image_url"])
+ page.order = int(page_seed.get("order") or index + 1)
+
+ for stale_page in existing_pages[len(pages) :]:
+ db.delete(stale_page)
+
+
+def _sync_lesson_activities(db, lesson: Lesson, activities: list[dict[str, object]]) -> None:
existing_activities = sorted(
list(lesson.activities or []),
key=lambda activity: (
@@ -1681,6 +1817,7 @@ def _sync_lesson_activities(db, lesson: Lesson, activities: list[dict[str, str |
activity.title = str(activity_seed["title"])
activity.content = str(activity_seed["content"])
activity.order = int(activity_seed["order"])
+ _sync_storybook_pages(db, activity, list(activity_seed.get("pages") or []))
for stale_activity in existing_activities[len(activities) :]:
db.delete(stale_activity)
diff --git a/backend/tests/test_demo_seed_phase1.py b/backend/tests/test_demo_seed_phase1.py
index 14e4a45..c202e13 100644
--- a/backend/tests/test_demo_seed_phase1.py
+++ b/backend/tests/test_demo_seed_phase1.py
@@ -92,6 +92,41 @@ def test_demo_seed_creates_canonical_org_users_and_flagship_relationships(db_eng
seeded_assessment_titles = {seed["title"] for seed in seed_demo.DEMO_FLAGSHIP_ASSESSMENTS}
actual_assessment_titles = {row[0] for row in session.query(Assessment.title).all()}
assert seeded_assessment_titles.issubset(actual_assessment_titles)
+
+ intro_course = session.query(Course).filter(Course.title == seed_demo.DEMO_COURSE_TITLE).one()
+ regions_lesson = next(
+ lesson
+ for unit in intro_course.units
+ for lesson in unit.lessons
+ if lesson.title == "Regions, Rivers, and Routes"
+ )
+ regions_lesson_activities = sorted(regions_lesson.activities, key=lambda activity: activity.order or 0)
+ assert [activity.type for activity in regions_lesson_activities[:2]] == ["storybook", "coloring"]
+
+ intro_lesson = next(
+ lesson
+ for unit in intro_course.units
+ for lesson in unit.lessons
+ if lesson.title == "Introduction to Africa"
+ )
+ intro_lesson_activities = sorted(intro_lesson.activities, key=lambda activity: activity.order or 0)
+ assert [activity.type for activity in intro_lesson_activities[:4]] == [
+ "image",
+ "interactive_map",
+ "storybook",
+ "coloring",
+ ]
+ storybook_activity = next(activity for activity in intro_lesson_activities if activity.type == "storybook")
+ assert [page.order for page in sorted(storybook_activity.storybook_pages, key=lambda page: page.order or 0)] == [1, 2, 3, 4]
+ assert [
+ page.image_url.rsplit("/", 1)[-1]
+ for page in sorted(storybook_activity.storybook_pages, key=lambda page: page.order or 0)
+ ] == [
+ "08ef622b-2b8a-41f0-8e43-74f4ee11d216.png",
+ "1ca7b568-f7eb-4877-bd9e-22919ec90a36.png",
+ "268b14ab-3b76-4153-84ac-5238658d1f72.png",
+ "1d7d06af-ad31-4ac4-ba83-82a720079317.png",
+ ]
finally:
session.close()
diff --git a/backend/tests/test_flagship_pathway_phase1.py b/backend/tests/test_flagship_pathway_phase1.py
index 9c560c4..78e847b 100644
--- a/backend/tests/test_flagship_pathway_phase1.py
+++ b/backend/tests/test_flagship_pathway_phase1.py
@@ -50,7 +50,7 @@ def test_flagship_lessons_include_ordered_meaningful_existing_activities(db_engi
session_factory = _build_session_factory(db_engine)
_seed_with_factory(monkeypatch, session_factory)
- allowed_types = {"story", "reflection", "checkpoint", "quiz"}
+ allowed_types = {"image", "interactive_map", "storybook", "coloring", "story", "reflection", "checkpoint", "quiz"}
session = session_factory()
try:
diff --git a/frontend/angular.json b/frontend/angular.json
index 95af24f..03b903d 100644
--- a/frontend/angular.json
+++ b/frontend/angular.json
@@ -29,6 +29,11 @@
{
"glob": "**/*",
"input": "public"
+ },
+ {
+ "glob": "**/*",
+ "input": "src/assets",
+ "output": "assets"
}
],
"styles": [
@@ -91,6 +96,11 @@
{
"glob": "**/*",
"input": "public"
+ },
+ {
+ "glob": "**/*",
+ "input": "src/assets",
+ "output": "assets"
}
],
"styles": [
diff --git a/frontend/src/app/app.routes.spec.ts b/frontend/src/app/app.routes.spec.ts
index a4404da..f5ad093 100644
--- a/frontend/src/app/app.routes.spec.ts
+++ b/frontend/src/app/app.routes.spec.ts
@@ -8,6 +8,7 @@ import { WorkspaceDashboardComponent } from './pages/workspace-dashboard/workspa
import { DemoReadinessComponent } from './pages/demo-readiness/demo-readiness.component';
import { PublicProductsComponent } from './pages/public-products/public-products.component';
import { PublicProductDetailComponent } from './pages/public-products/public-product-detail.component';
+import { RoleGuard } from './guards/role.guard';
function findRoute(routeList: Routes, path: string): Route | undefined {
const exactRoute = routeList.find(candidate => candidate.path === path);
@@ -69,6 +70,10 @@ describe('app routes', () => {
].forEach(path => expect(findRoute(routes, path)).withContext(path).toBeTruthy());
expect(findRoute(routes, 'workspace/')?.component).toBe(WorkspaceDashboardComponent);
expect(findRoute(routes, 'workspace/demo-readiness')?.component).toBe(DemoReadinessComponent);
+ expect(findRoute(routes, 'workspace/')?.canActivate).toContain(RoleGuard);
+ expect(findRoute(routes, 'workspace/products')?.canActivate).toContain(RoleGuard);
+ expect(findRoute(routes, 'workspace/products/:productId')?.canActivate).toContain(RoleGuard);
+ expect(findRoute(routes, 'workspace/learners')?.canActivate).toContain(RoleGuard);
});
it('adds learner portal aliases without removing existing learner pages', () => {
diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts
index 135f81f..ceb609b 100644
--- a/frontend/src/app/app.routes.ts
+++ b/frontend/src/app/app.routes.ts
@@ -136,6 +136,8 @@ export const routes: Routes = [
{
path: '',
component: WorkspaceDashboardComponent,
+ canActivate: [RoleGuard],
+ data: { roles: creatorRoles }
},
{
path: 'projects',
@@ -208,7 +210,9 @@ export const routes: Routes = [
{
path: 'products',
component: V2CollectionPageComponent,
+ canActivate: [RoleGuard],
data: {
+ roles: creatorRoles,
eyebrow: 'Products',
title: 'Product catalog',
status: 'Real V2 product wrappers. Course-backed products link to the existing education runtime.',
@@ -219,6 +223,8 @@ export const routes: Routes = [
{
path: 'products/:productId',
component: ProductDetailComponent,
+ canActivate: [RoleGuard],
+ data: { roles: creatorRoles }
},
{
path: 'products/manage',
@@ -273,6 +279,8 @@ export const routes: Routes = [
{
path: 'learners',
component: UserDashboardComponent,
+ canActivate: [RoleGuard],
+ data: { roles: creatorRoles },
children: [
{ path: '', component: EchoedRoleSelectorComponent },
{ path: 'products', component: AvailableCoursesComponent },
@@ -316,7 +324,12 @@ export const routes: Routes = [
canActivate: [RoleGuard],
data: { roles: creatorRoles }
},
- { path: 'settings', component: PreferencesComponent },
+ {
+ path: 'settings',
+ component: PreferencesComponent,
+ canActivate: [RoleGuard],
+ data: { roles: creatorRoles }
+ },
{
path: 'settings/invites',
component: OrgInvitesComponent,
diff --git a/frontend/src/app/components/africa-map-explorer/africa-map-explorer.component.ts b/frontend/src/app/components/africa-map-explorer/africa-map-explorer.component.ts
new file mode 100644
index 0000000..0e94682
--- /dev/null
+++ b/frontend/src/app/components/africa-map-explorer/africa-map-explorer.component.ts
@@ -0,0 +1,132 @@
+import { Component, Input } from '@angular/core';
+import { CommonModule } from '@angular/common';
+
+export interface AfricaMapRegion {
+ id: string;
+ label: string;
+ fact: string;
+ focus?: string[];
+ x: number;
+ y: number;
+}
+
+export interface AfricaMapExplorerData {
+ title?: string;
+ prompt?: string;
+ contextImageUrl?: string;
+ referenceImageUrl?: string;
+ referenceImageAlt?: string;
+ regions?: AfricaMapRegion[];
+}
+
+@Component({
+ selector: 'app-africa-map-explorer',
+ standalone: true,
+ imports: [CommonModule],
+ template: `
+
+ {{ data?.title || 'Africa map explorer' }}
+
+ {{ data?.prompt }}
+ World context Reference map Regions Map reading note
+ {{ selectedRegion?.fact || 'Select a region to see one map-reading clue and a question to discuss.' }}
+
+ Explore Africa by region
+
+
+
+
+
+ {{ selectedRegion?.label || 'Choose a region' }}
+
+
+
+
Demo tour {{ stepIndex }} / {{ totalSteps }}
+Demo tour {{ stepIndex }} / {{ totalSteps }}
@if (step) {{{ step.description }}
+{{ step.description }}
}