diff --git a/backend/alembic/versions/a2f4c6d8e9b1_add_v2_platform_wrapper_models.py b/backend/alembic/versions/a2f4c6d8e9b1_add_v2_platform_wrapper_models.py new file mode 100644 index 0000000..37cc110 --- /dev/null +++ b/backend/alembic/versions/a2f4c6d8e9b1_add_v2_platform_wrapper_models.py @@ -0,0 +1,286 @@ +"""add v2 platform wrapper models + +Revision ID: a2f4c6d8e9b1 +Revises: 71d5d3a8f680, 9f1c2d3e4a5b +Create Date: 2026-06-30 12:35:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import uuid +from datetime import datetime + + +revision: str = "a2f4c6d8e9b1" +down_revision: Union[str, tuple[str, str], None] = ("71d5d3a8f680", "9f1c2d3e4a5b") +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "workspaces", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("organization_id", sa.UUID(), nullable=True), + sa.Column("name", sa.String(), nullable=False), + sa.Column("slug", sa.String(), nullable=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("status", sa.String(), nullable=False, server_default="active"), + sa.Column("metadata", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("organization_id", name="uq_workspace_organization"), + sa.UniqueConstraint("slug"), + ) + op.create_index(op.f("ix_workspaces_id"), "workspaces", ["id"], unique=False) + op.create_index(op.f("ix_workspaces_slug"), "workspaces", ["slug"], unique=False) + + op.create_table( + "projects", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("workspace_id", sa.UUID(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("status", sa.String(), nullable=False, server_default="active"), + sa.Column("metadata", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_projects_id"), "projects", ["id"], unique=False) + op.create_index("ix_projects_workspace_id", "projects", ["workspace_id"], unique=False) + + op.create_table( + "products", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("workspace_id", sa.UUID(), nullable=False), + sa.Column("project_id", sa.UUID(), nullable=True), + sa.Column("course_id", sa.UUID(), nullable=True), + sa.Column("program_id", sa.UUID(), nullable=True), + sa.Column("product_type", sa.String(), nullable=False, server_default="course"), + sa.Column("title", sa.String(), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("status", sa.String(), nullable=False, server_default="draft"), + sa.Column("review_state", sa.String(), nullable=False, server_default="not_reviewed"), + sa.Column("access_state", sa.String(), nullable=False, server_default="private"), + sa.Column("metadata", sa.JSON(), nullable=True), + sa.Column("published_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["course_id"], ["courses.id"]), + sa.ForeignKeyConstraint(["program_id"], ["programs.id"]), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"]), + sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("course_id", name="uq_product_course"), + sa.UniqueConstraint("program_id", name="uq_product_program"), + ) + op.create_index(op.f("ix_products_id"), "products", ["id"], unique=False) + op.create_index("ix_products_workspace_id", "products", ["workspace_id"], unique=False) + op.create_index("ix_products_project_id", "products", ["project_id"], unique=False) + + op.create_table( + "knowledge_sources", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("workspace_id", sa.UUID(), nullable=False), + sa.Column("project_id", sa.UUID(), nullable=False), + sa.Column("source_id", sa.UUID(), nullable=True), + sa.Column("title", sa.String(), nullable=False), + sa.Column("source_type", sa.String(), nullable=False, server_default="document"), + sa.Column("uri", sa.String(), nullable=True), + sa.Column("citation", sa.Text(), nullable=True), + sa.Column("content_hash", sa.String(), nullable=True), + sa.Column("status", sa.String(), nullable=False, server_default="available"), + sa.Column("metadata", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"]), + sa.ForeignKeyConstraint(["source_id"], ["sources.id"]), + sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_knowledge_sources_id"), "knowledge_sources", ["id"], unique=False) + op.create_index("ix_knowledge_sources_project_id", "knowledge_sources", ["project_id"], unique=False) + + op.create_table( + "generation_runs", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("workspace_id", sa.UUID(), nullable=False), + sa.Column("project_id", sa.UUID(), nullable=False), + sa.Column("product_id", sa.UUID(), nullable=True), + sa.Column("status", sa.String(), nullable=False, server_default="queued"), + sa.Column("provider", sa.String(), nullable=True), + sa.Column("model_name", sa.String(), nullable=True), + sa.Column("prompt", sa.Text(), nullable=True), + sa.Column("output_summary", sa.Text(), nullable=True), + sa.Column("input_metadata", sa.JSON(), nullable=True), + sa.Column("output_metadata", sa.JSON(), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("started_at", sa.DateTime(), nullable=True), + sa.Column("completed_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["product_id"], ["products.id"]), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"]), + sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_generation_runs_id"), "generation_runs", ["id"], unique=False) + op.create_index("ix_generation_runs_project_id", "generation_runs", ["project_id"], unique=False) + + op.create_table( + "artifacts", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("workspace_id", sa.UUID(), nullable=False), + sa.Column("project_id", sa.UUID(), nullable=False), + sa.Column("product_id", sa.UUID(), nullable=True), + sa.Column("generation_run_id", sa.UUID(), nullable=True), + sa.Column("knowledge_source_id", sa.UUID(), nullable=True), + sa.Column("artifact_type", sa.String(), nullable=False, server_default="documentation"), + sa.Column("title", sa.String(), nullable=False), + sa.Column("body", sa.Text(), nullable=True), + sa.Column("uri", sa.String(), nullable=True), + sa.Column("status", sa.String(), nullable=False, server_default="draft"), + sa.Column("review_state", sa.String(), nullable=False, server_default="review_required"), + sa.Column("metadata", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["generation_run_id"], ["generation_runs.id"]), + sa.ForeignKeyConstraint(["knowledge_source_id"], ["knowledge_sources.id"]), + sa.ForeignKeyConstraint(["product_id"], ["products.id"]), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"]), + sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_artifacts_id"), "artifacts", ["id"], unique=False) + op.create_index("ix_artifacts_project_id", "artifacts", ["project_id"], unique=False) + + _backfill_workspace_and_products() + + +def downgrade() -> None: + op.drop_index("ix_artifacts_project_id", table_name="artifacts") + op.drop_index(op.f("ix_artifacts_id"), table_name="artifacts") + op.drop_table("artifacts") + + op.drop_index("ix_generation_runs_project_id", table_name="generation_runs") + op.drop_index(op.f("ix_generation_runs_id"), table_name="generation_runs") + op.drop_table("generation_runs") + + op.drop_index("ix_knowledge_sources_project_id", table_name="knowledge_sources") + op.drop_index(op.f("ix_knowledge_sources_id"), table_name="knowledge_sources") + op.drop_table("knowledge_sources") + + op.drop_index("ix_products_project_id", table_name="products") + op.drop_index("ix_products_workspace_id", table_name="products") + op.drop_index(op.f("ix_products_id"), table_name="products") + op.drop_table("products") + + op.drop_index("ix_projects_workspace_id", table_name="projects") + op.drop_index(op.f("ix_projects_id"), table_name="projects") + op.drop_table("projects") + + op.drop_index(op.f("ix_workspaces_slug"), table_name="workspaces") + op.drop_index(op.f("ix_workspaces_id"), table_name="workspaces") + op.drop_table("workspaces") + + +def _backfill_workspace_and_products() -> None: + connection = op.get_bind() + now = datetime.utcnow() + workspace_ids_by_org: dict[str, uuid.UUID] = {} + + organizations = connection.execute(sa.text("SELECT id, name FROM organizations")).fetchall() + for organization_id, organization_name in organizations: + workspace_id = uuid.uuid4() + workspace_ids_by_org[str(organization_id)] = workspace_id + connection.execute( + sa.text( + "INSERT INTO workspaces (id, organization_id, name, status, created_at, updated_at) " + "VALUES (:id, :organization_id, :name, 'active', :created_at, :updated_at)" + ), + { + "id": workspace_id, + "organization_id": organization_id, + "name": organization_name, + "created_at": now, + "updated_at": now, + }, + ) + + legacy_workspace_id: uuid.UUID | None = None + + def legacy_workspace() -> uuid.UUID: + nonlocal legacy_workspace_id + if legacy_workspace_id is None: + legacy_workspace_id = uuid.uuid4() + connection.execute( + sa.text( + "INSERT INTO workspaces (id, name, status, created_at, updated_at) " + "VALUES (:id, 'Legacy EchoEd Workspace', 'active', :created_at, :updated_at)" + ), + {"id": legacy_workspace_id, "created_at": now, "updated_at": now}, + ) + return legacy_workspace_id + + courses = connection.execute( + sa.text("SELECT id, title, description, organization_id FROM courses") + ).fetchall() + for course_id, title, description, organization_id in courses: + workspace_id = ( + workspace_ids_by_org.get(str(organization_id)) + if organization_id is not None + else legacy_workspace() + ) + if workspace_id is None: + workspace_id = legacy_workspace() + connection.execute( + sa.text( + "INSERT INTO products " + "(id, workspace_id, course_id, product_type, title, description, status, review_state, access_state, created_at, updated_at) " + "VALUES (:id, :workspace_id, :course_id, 'course', :title, :description, 'active', " + "'runtime_authoritative', 'existing_runtime', :created_at, :updated_at)" + ), + { + "id": uuid.uuid4(), + "workspace_id": workspace_id, + "course_id": course_id, + "title": title, + "description": description, + "created_at": now, + "updated_at": now, + }, + ) + + programs = connection.execute( + sa.text("SELECT id, title, description, organization_id FROM programs") + ).fetchall() + for program_id, title, description, organization_id in programs: + workspace_id = ( + workspace_ids_by_org.get(str(organization_id)) + if organization_id is not None + else legacy_workspace() + ) + if workspace_id is None: + workspace_id = legacy_workspace() + connection.execute( + sa.text( + "INSERT INTO products " + "(id, workspace_id, program_id, product_type, title, description, status, review_state, access_state, created_at, updated_at) " + "VALUES (:id, :workspace_id, :program_id, 'learning_path', :title, :description, 'active', " + "'runtime_authoritative', 'existing_runtime', :created_at, :updated_at)" + ), + { + "id": uuid.uuid4(), + "workspace_id": workspace_id, + "program_id": program_id, + "title": title, + "description": description, + "created_at": now, + "updated_at": now, + }, + ) diff --git a/backend/alembic/versions/b3d2c1e4f607_add_access_grants.py b/backend/alembic/versions/b3d2c1e4f607_add_access_grants.py new file mode 100644 index 0000000..57ce3e2 --- /dev/null +++ b/backend/alembic/versions/b3d2c1e4f607_add_access_grants.py @@ -0,0 +1,153 @@ +"""add access grants + +Revision ID: b3d2c1e4f607 +Revises: a2f4c6d8e9b1 +Create Date: 2026-06-30 16:55:00.000000 +""" + +from datetime import datetime +from typing import Sequence, Union +import uuid + +from alembic import op +import sqlalchemy as sa + + +revision: str = "b3d2c1e4f607" +down_revision: Union[str, Sequence[str], None] = "a2f4c6d8e9b1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "access_grants", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("product_id", sa.UUID(), nullable=False), + sa.Column("workspace_id", sa.UUID(), nullable=False), + sa.Column("project_id", sa.UUID(), nullable=True), + sa.Column("grant_type", sa.String(), nullable=False, server_default="manual"), + sa.Column("status", sa.String(), nullable=False, server_default="active"), + sa.Column("source", sa.String(), nullable=False, server_default="manual"), + sa.Column("starts_at", sa.DateTime(), nullable=True), + sa.Column("expires_at", sa.DateTime(), nullable=True), + sa.Column("revoked_at", sa.DateTime(), nullable=True), + sa.Column("metadata", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["product_id"], ["products.id"]), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"]), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "product_id", "grant_type", name="uq_access_grant_user_product_type"), + ) + op.create_index(op.f("ix_access_grants_id"), "access_grants", ["id"], unique=False) + op.create_index("ix_access_grants_user_id", "access_grants", ["user_id"], unique=False) + op.create_index("ix_access_grants_product_id", "access_grants", ["product_id"], unique=False) + op.create_index("ix_access_grants_workspace_id", "access_grants", ["workspace_id"], unique=False) + _backfill_enrollment_grants() + + +def downgrade() -> None: + op.drop_index("ix_access_grants_workspace_id", table_name="access_grants") + op.drop_index("ix_access_grants_product_id", table_name="access_grants") + op.drop_index("ix_access_grants_user_id", table_name="access_grants") + op.drop_index(op.f("ix_access_grants_id"), table_name="access_grants") + op.drop_table("access_grants") + + +def _backfill_enrollment_grants() -> None: + connection = op.get_bind() + now = datetime.utcnow() + rows = [] + seen: set[tuple[object, object]] = set() + + course_rows = connection.execute( + sa.text( + """ + SELECT + sc.student_id AS user_id, + sc.status AS enrollment_status, + sc.enrolled_on AS starts_at, + p.id AS product_id, + p.workspace_id AS workspace_id, + p.project_id AS project_id + FROM student_courses sc + JOIN products p ON p.course_id = sc.course_id + """ + ) + ).mappings() + for row in course_rows: + key = (row["user_id"], row["product_id"]) + if key in seen: + continue + seen.add(key) + rows.append(_grant_row(row, now, "student_course_backfill")) + + program_rows = connection.execute( + sa.text( + """ + SELECT + spp.student_id AS user_id, + spp.status AS enrollment_status, + spp.enrolled_on AS starts_at, + p.id AS product_id, + p.workspace_id AS workspace_id, + p.project_id AS project_id + FROM student_program_progress spp + JOIN products p ON p.program_id = spp.program_id + """ + ) + ).mappings() + for row in program_rows: + key = (row["user_id"], row["product_id"]) + if key in seen: + continue + seen.add(key) + rows.append(_grant_row(row, now, "student_program_progress_backfill")) + + if rows: + op.bulk_insert( + sa.table( + "access_grants", + sa.column("id", sa.UUID()), + sa.column("user_id", sa.UUID()), + sa.column("product_id", sa.UUID()), + sa.column("workspace_id", sa.UUID()), + sa.column("project_id", sa.UUID()), + sa.column("grant_type", sa.String()), + sa.column("status", sa.String()), + sa.column("source", sa.String()), + sa.column("starts_at", sa.DateTime()), + sa.column("expires_at", sa.DateTime()), + sa.column("revoked_at", sa.DateTime()), + sa.column("metadata", sa.JSON()), + sa.column("created_at", sa.DateTime()), + sa.column("updated_at", sa.DateTime()), + ), + rows, + ) + + +def _grant_row(row, now: datetime, source: str) -> dict: + enrollment_status = (row["enrollment_status"] or "active").lower() + status = "revoked" if enrollment_status == "withdrawn" else "active" + revoked_at = now if status == "revoked" else None + return { + "id": uuid.uuid4(), + "user_id": row["user_id"], + "product_id": row["product_id"], + "workspace_id": row["workspace_id"], + "project_id": row["project_id"], + "grant_type": "enrollment", + "status": status, + "source": source, + "starts_at": row["starts_at"], + "expires_at": None, + "revoked_at": revoked_at, + "metadata": {"source_enrollment_status": enrollment_status}, + "created_at": now, + "updated_at": now, + } diff --git a/backend/app/api/routes/analytics.py b/backend/app/api/routes/analytics.py index b5cb54f..de237a9 100644 --- a/backend/app/api/routes/analytics.py +++ b/backend/app/api/routes/analytics.py @@ -1,24 +1,35 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import or_ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import desc, func, or_ from sqlalchemy.orm import Session, selectinload from uuid import UUID from app.crud.badges import BADGE_RULES, calculate_streak_days, get_or_create_badge from app.database import get_db from app.deps import require_roles, require_org_roles -from app.enum import ProgressStatus +from app.enum import MembershipStatus, ProgressStatus from app.models import ( + AccessGrant, Assessment, + Artifact, StudentAssessmentAttempt, + Certification, Course, + GenerationRun, + KnowledgeSource, Lesson, + OrganizationMembership, + Product, + Project, SegmentProgress, StudentBadge, + StudentCertification, StudentCourse, + StudentProgramProgress, StudentUnitProgress, Unit, User, Enrollment, + Workspace, ) from app.schemas import ( AssessmentEvidenceSummaryResponse, @@ -33,6 +44,164 @@ router = APIRouter() FLAGSHIP_PATHWAY_KEY = "introduction-to-africa" +V2_ANALYTICS_ROLES = ("admin", "teacher", "content_admin", "org_admin", "instructor", "super_admin") + + +def _visible_v2_workspace_ids(db: Session, current_user: User) -> list[UUID] | None: + if current_user.role in {"admin", "super_admin"}: + return None + + memberships = db.query(OrganizationMembership).filter( + OrganizationMembership.user_id == current_user.id, + ).all() + organization_ids = [ + membership.organization_id + for membership in memberships + if getattr(membership.status, "value", membership.status) == MembershipStatus.ACTIVE.value + ] + if not organization_ids: + return [] + + return [ + workspace.id + for workspace in db.query(Workspace.id) + .filter(Workspace.organization_id.in_(organization_ids)) + .all() + ] + + +def _normalize_workspace_scope( + db: Session, + current_user: User, + workspace_id: UUID | None = None, +) -> list[UUID] | None: + visible_ids = _visible_v2_workspace_ids(db, current_user) + if workspace_id is None: + return visible_ids + if visible_ids is not None and workspace_id not in visible_ids: + return [] + return [workspace_id] + + +def _scope_v2_query(query, model, workspace_ids: list[UUID] | None): + if workspace_ids is None: + return query + if not workspace_ids: + return query.filter(False) + return query.filter(model.workspace_id.in_(workspace_ids)) + + +def _count_by(db: Session, model, column, workspace_ids: list[UUID] | None) -> dict[str, int]: + rows = ( + _scope_v2_query(db.query(column, func.count(model.id)), model, workspace_ids) + .group_by(column) + .all() + ) + return {str(value or "unknown"): count for value, count in rows} + + +def _recent_items(db: Session, model, workspace_ids: list[UUID] | None, *, item_type: str, limit: int = 5): + rows = ( + _scope_v2_query(db.query(model), model, workspace_ids) + .order_by(desc(model.created_at), model.id.asc()) + .limit(limit) + .all() + ) + items = [] + for row in rows: + items.append( + { + "id": row.id, + "item_type": item_type, + "title": getattr(row, "title", getattr(row, "name", "Untitled")), + "status": getattr(row, "status", "unknown"), + "subtype": getattr(row, "product_type", getattr(row, "artifact_type", getattr(row, "source_type", None))), + "created_at": row.created_at, + } + ) + return items + + +def _scoped_product_ids(db: Session, workspace_ids: list[UUID] | None) -> list[UUID]: + return [row.id for row in _scope_v2_query(db.query(Product.id), Product, workspace_ids).all()] + + +def _scoped_course_ids(db: Session, workspace_ids: list[UUID] | None) -> list[UUID]: + return [ + row.course_id + for row in _scope_v2_query(db.query(Product.course_id), Product, workspace_ids) + .filter(Product.course_id.isnot(None)) + .all() + ] + + +def _scoped_program_ids(db: Session, workspace_ids: list[UUID] | None) -> list[UUID]: + return [ + row.program_id + for row in _scope_v2_query(db.query(Product.program_id), Product, workspace_ids) + .filter(Product.program_id.isnot(None)) + .all() + ] + + +def _learner_engagement_summary(db: Session, workspace_ids: list[UUID] | None) -> dict: + course_ids = _scoped_course_ids(db, workspace_ids) + program_ids = _scoped_program_ids(db, workspace_ids) + product_ids = _scoped_product_ids(db, workspace_ids) + + course_enrollments = db.query(StudentCourse) + if course_ids: + course_enrollments = course_enrollments.filter(StudentCourse.course_id.in_(course_ids)) + elif workspace_ids is not None: + course_enrollments = course_enrollments.filter(False) + + program_enrollments = db.query(StudentProgramProgress) + if program_ids: + program_enrollments = program_enrollments.filter(StudentProgramProgress.program_id.in_(program_ids)) + elif workspace_ids is not None: + program_enrollments = program_enrollments.filter(False) + + access_grants = db.query(AccessGrant) + if product_ids: + access_grants = access_grants.filter(AccessGrant.product_id.in_(product_ids)) + elif workspace_ids is not None: + access_grants = access_grants.filter(False) + + course_rows = course_enrollments.all() + program_rows = program_enrollments.all() + grant_rows = access_grants.all() + learner_ids = {row.student_id for row in course_rows} | {row.student_id for row in program_rows} | {row.user_id for row in grant_rows} + + completed_courses = sum(1 for row in course_rows if row.status == "completed") + lesson_completion_count = ( + db.query(SegmentProgress) + .join(StudentUnitProgress, StudentUnitProgress.id == SegmentProgress.student_unit_id) + .join(StudentCourse, StudentCourse.id == StudentUnitProgress.student_course_id) + .filter( + SegmentProgress.status == ProgressStatus.COMPLETED, + StudentCourse.id.in_([row.id for row in course_rows]) if course_rows else False, + ) + .count() + ) + issued_certifications = db.query(StudentCertification).count() if workspace_ids is None else 0 + if program_ids: + issued_certifications = ( + db.query(StudentCertification) + .join(Certification, Certification.id == StudentCertification.certification_id) + .filter(Certification.program_id.in_(program_ids)) + .count() + ) + + return { + "learner_count": len(learner_ids), + "enrollment_count": len(course_rows) + len(program_rows), + "course_enrollment_count": len(course_rows), + "program_enrollment_count": len(program_rows), + "completed_course_count": completed_courses, + "lesson_completion_count": lesson_completion_count, + "issued_certification_count": issued_certifications, + "access_granted_learner_count": len({row.user_id for row in grant_rows}), + } def _latest_attempt_for_assessment(assessment: Assessment, student_id): @@ -560,6 +729,159 @@ def _build_assessment_evidence_summary( return rows +@router.get("/analytics/workspace") +def get_v2_workspace_analytics( + workspace_id: UUID | None = Query(default=None), + db: Session = Depends(get_db), + current_user=Depends(require_roles(*V2_ANALYTICS_ROLES)), +): + workspace_ids = _normalize_workspace_scope(db, current_user, workspace_id) + learner_summary = _learner_engagement_summary(db, workspace_ids) + product_ids = _scoped_product_ids(db, workspace_ids) + + return { + "totals": { + "total_products": _scope_v2_query(db.query(Product), Product, workspace_ids).count(), + "total_projects": _scope_v2_query(db.query(Project), Project, workspace_ids).count(), + "total_knowledge_sources": _scope_v2_query(db.query(KnowledgeSource), KnowledgeSource, workspace_ids).count(), + "total_artifacts": _scope_v2_query(db.query(Artifact), Artifact, workspace_ids).count(), + "total_generation_runs": _scope_v2_query(db.query(GenerationRun), GenerationRun, workspace_ids).count(), + "total_access_grants": _scope_v2_query(db.query(AccessGrant), AccessGrant, workspace_ids).count(), + "learner_count": learner_summary["learner_count"], + "enrollment_count": learner_summary["enrollment_count"], + "course_backed_product_count": _scope_v2_query(db.query(Product), Product, workspace_ids).filter(Product.course_id.isnot(None)).count(), + "program_backed_product_count": _scope_v2_query(db.query(Product), Product, workspace_ids).filter(Product.program_id.isnot(None)).count(), + }, + "products_by_type": _count_by(db, Product, Product.product_type, workspace_ids), + "products_by_status": _count_by(db, Product, Product.status, workspace_ids), + "artifacts_by_status": _count_by(db, Artifact, Artifact.status, workspace_ids), + "artifacts_by_type": _count_by(db, Artifact, Artifact.artifact_type, workspace_ids), + "generation_runs_by_status": _count_by(db, GenerationRun, GenerationRun.status, workspace_ids), + "access_grants_by_status": _count_by(db, AccessGrant, AccessGrant.status, workspace_ids), + "access_grants_by_type": _count_by(db, AccessGrant, AccessGrant.grant_type, workspace_ids), + "review_health": { + "draft_products": _scope_v2_query(db.query(Product), Product, workspace_ids).filter(Product.status == "draft").count(), + "in_review_products": _scope_v2_query(db.query(Product), Product, workspace_ids).filter(Product.status == "in_review").count(), + "approved_products": _scope_v2_query(db.query(Product), Product, workspace_ids).filter(Product.status == "approved").count(), + "published_products": _scope_v2_query(db.query(Product), Product, workspace_ids).filter(Product.status == "published").count(), + "review_required_artifacts": _scope_v2_query(db.query(Artifact), Artifact, workspace_ids).filter(Artifact.status.in_(["draft", "in_review", "needs_changes"])).count(), + }, + "learner_engagement": learner_summary, + "recent": { + "products": _recent_items(db, Product, workspace_ids, item_type="product"), + "artifacts": _recent_items(db, Artifact, workspace_ids, item_type="artifact"), + "knowledge_sources": _recent_items(db, KnowledgeSource, workspace_ids, item_type="knowledge_source"), + }, + "event_tracking": { + "status": "unavailable", + "message": "Analytics V2 foundations use current wrapper and runtime state; event tracking has not been introduced.", + }, + "scoped_product_count": len(product_ids), + } + + +@router.get("/analytics/products") +def get_v2_product_analytics( + workspace_id: UUID | None = Query(default=None), + db: Session = Depends(get_db), + current_user=Depends(require_roles(*V2_ANALYTICS_ROLES)), +): + workspace_ids = _normalize_workspace_scope(db, current_user, workspace_id) + products = ( + _scope_v2_query(db.query(Product), Product, workspace_ids) + .order_by(Product.updated_at.desc(), Product.title.asc()) + .all() + ) + product_rows = [] + for product in products: + enrollment_count = 0 + if product.course_id: + enrollment_count = db.query(StudentCourse).filter(StudentCourse.course_id == product.course_id).count() + if product.program_id: + enrollment_count += db.query(StudentProgramProgress).filter(StudentProgramProgress.program_id == product.program_id).count() + product_rows.append( + { + "id": product.id, + "title": product.title, + "product_type": product.product_type, + "status": product.status, + "review_state": product.review_state, + "access_state": product.access_state, + "course_id": product.course_id, + "program_id": product.program_id, + "artifact_count": db.query(Artifact).filter(Artifact.product_id == product.id).count(), + "generation_run_count": db.query(GenerationRun).filter(GenerationRun.product_id == product.id).count(), + "access_grant_count": db.query(AccessGrant).filter(AccessGrant.product_id == product.id).count(), + "enrollment_count": enrollment_count, + "created_at": product.created_at, + "updated_at": product.updated_at, + } + ) + + return { + "total_products": len(products), + "course_backed_product_count": sum(1 for product in products if product.course_id is not None), + "program_backed_product_count": sum(1 for product in products if product.program_id is not None), + "products_by_type": _count_by(db, Product, Product.product_type, workspace_ids), + "products_by_status": _count_by(db, Product, Product.status, workspace_ids), + "products": product_rows, + } + + +@router.get("/analytics/learners") +def get_v2_learner_analytics( + workspace_id: UUID | None = Query(default=None), + db: Session = Depends(get_db), + current_user=Depends(require_roles(*V2_ANALYTICS_ROLES)), +): + workspace_ids = _normalize_workspace_scope(db, current_user, workspace_id) + course_ids = _scoped_course_ids(db, workspace_ids) + enrollment_query = db.query(StudentCourse.status, func.count(StudentCourse.id)) + if course_ids: + enrollment_query = enrollment_query.filter(StudentCourse.course_id.in_(course_ids)) + elif workspace_ids is not None: + enrollment_query = enrollment_query.filter(False) + + return { + "summary": _learner_engagement_summary(db, workspace_ids), + "enrollments_by_status": { + str(status or "unknown"): count + for status, count in enrollment_query.group_by(StudentCourse.status).all() + }, + "access_grants_by_status": _count_by(db, AccessGrant, AccessGrant.status, workspace_ids), + "access_grants_by_type": _count_by(db, AccessGrant, AccessGrant.grant_type, workspace_ids), + } + + +@router.get("/analytics/knowledge-pipeline") +def get_v2_knowledge_pipeline_analytics( + workspace_id: UUID | None = Query(default=None), + db: Session = Depends(get_db), + current_user=Depends(require_roles(*V2_ANALYTICS_ROLES)), +): + workspace_ids = _normalize_workspace_scope(db, current_user, workspace_id) + return { + "total_projects": _scope_v2_query(db.query(Project), Project, workspace_ids).count(), + "total_knowledge_sources": _scope_v2_query(db.query(KnowledgeSource), KnowledgeSource, workspace_ids).count(), + "total_artifacts": _scope_v2_query(db.query(Artifact), Artifact, workspace_ids).count(), + "total_generation_runs": _scope_v2_query(db.query(GenerationRun), GenerationRun, workspace_ids).count(), + "knowledge_sources_by_type": _count_by(db, KnowledgeSource, KnowledgeSource.source_type, workspace_ids), + "knowledge_sources_by_status": _count_by(db, KnowledgeSource, KnowledgeSource.status, workspace_ids), + "artifacts_by_status": _count_by(db, Artifact, Artifact.status, workspace_ids), + "artifacts_by_type": _count_by(db, Artifact, Artifact.artifact_type, workspace_ids), + "generation_runs_by_status": _count_by(db, GenerationRun, GenerationRun.status, workspace_ids), + "recent": { + "artifacts": _recent_items(db, Artifact, workspace_ids, item_type="artifact"), + "knowledge_sources": _recent_items(db, KnowledgeSource, workspace_ids, item_type="knowledge_source"), + }, + "source_coverage": { + "status": "advisory", + "artifacts_with_knowledge_source": _scope_v2_query(db.query(Artifact), Artifact, workspace_ids).filter(Artifact.knowledge_source_id.isnot(None)).count(), + "artifacts_without_knowledge_source": _scope_v2_query(db.query(Artifact), Artifact, workspace_ids).filter(Artifact.knowledge_source_id.is_(None)).count(), + }, + } + + @router.get("/analytics/overview") def get_admin_overview( db: Session = Depends(get_db), diff --git a/backend/app/api/routes/courses.py b/backend/app/api/routes/courses.py index 28040fd..a0ee2e4 100644 --- a/backend/app/api/routes/courses.py +++ b/backend/app/api/routes/courses.py @@ -107,6 +107,33 @@ def _serialize_runtime_intervention_evidence_basis(basis) -> RuntimeIntervention ) +def _serialize_course_summary(course: Course) -> CourseSummaryResponse: + return CourseSummaryResponse( + id=course.id, + title=course.title, + description=course.description, + subject=course.subject, + age_band_min=course.age_band_min, + age_band_max=course.age_band_max, + default_locale=course.default_locale, + learning_objectives=course.learning_objectives, + skill_tags=course.skill_tags or [], + standards_metadata=course.standards_metadata or {}, + revision_number=course.revision_number, + revision_label=course.revision_label, + revision_status=course.revision_status, + revision_metadata=course.revision_metadata or {}, + previous_revision_id=course.previous_revision_id, + superseded_by_id=course.superseded_by_id, + lineage_status=course.lineage_status, + lineage_metadata=course.lineage_metadata or {}, + published_at=course.published_at, + deprecated_at=course.deprecated_at, + created_by=course.created_by, + organization_id=course.organization_id, + ) + + @router.get("/courses", response_model=list[CourseSummaryResponse]) def get_courses( subject: str | None = Query(default=None), @@ -128,7 +155,7 @@ def get_courses( query = query.filter(Course.default_locale == locale) if org_id: query = query.filter(Course.organization_id == org_id) - return query.all() + return [_serialize_course_summary(course) for course in query.all()] @router.get("/student-courses", response_model=list[StudentCourseWithDetails]) diff --git a/backend/app/api/routes/progress.py b/backend/app/api/routes/progress.py index 58c06b0..391169b 100644 --- a/backend/app/api/routes/progress.py +++ b/backend/app/api/routes/progress.py @@ -54,6 +54,12 @@ def get_segment( if not unit_progress: raise HTTPException(status_code=404, detail="Unit progress not found.") + current_segment = crud.resolve_governed_segment_for_unit_progress( + db, student_unit_id + ) + if current_segment is not None: + return SegmentResponse(**current_segment) + progression = crud.resolve_governed_progression(db, unit_progress.student_course_id) if progression["delivery_state"] != "governed_available": return SegmentResponse( diff --git a/backend/app/api/routes/start_course.py b/backend/app/api/routes/start_course.py index 8866c28..b4aad94 100644 --- a/backend/app/api/routes/start_course.py +++ b/backend/app/api/routes/start_course.py @@ -4,6 +4,7 @@ from app.crud import progress as crud from app.database import get_db from app.deps import require_roles +from app.lesson_governance import GOVERNED_AVAILABLE from app.log import logger from app.models import StudentCourse from app.schemas import SegmentResponse, StartCourseRequest @@ -31,4 +32,13 @@ def start_course( raise HTTPException(status_code=400, detail="Course already completed") progression = crud.resolve_governed_progression(db, enrollment.id) + if ( + progression.get("delivery_state") == GOVERNED_AVAILABLE + and progression.get("unit_progress_id") is not None + ): + ensured_progression = crud.resolve_governed_segment_for_unit_progress( + db, progression["unit_progress_id"] + ) + if ensured_progression is not None: + progression = ensured_progression return SegmentResponse(**progression) diff --git a/backend/app/api/routes/v2_platform.py b/backend/app/api/routes/v2_platform.py new file mode 100644 index 0000000..26b547a --- /dev/null +++ b/backend/app/api/routes/v2_platform.py @@ -0,0 +1,1067 @@ +from datetime import datetime +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session, joinedload + +from app.database import get_db +from app.deps import get_current_user +from app.enum import MembershipStatus +from app.lesson_governance import evaluate_course_publish_readiness +from app.models import ( + AccessGrant, + Artifact, + GenerationRun, + KnowledgeSource, + OrganizationMembership, + Product, + Course, + Lesson, + Program, + Project, + Source, + StudentCourse, + Unit, + User, + Workspace, +) +from app.schemas import ( + AccessGrantCreateRequest, + AccessGrantResponse, + ArtifactCreateRequest, + ArtifactResponse, + GenerationRunResponse, + KnowledgeSourceCreateRequest, + KnowledgeSourceResponse, + ProductCreateRequest, + ProductResponse, + ProjectCreateRequest, + ProjectResponse, + LearnerProductResponse, + ReviewCenterActivityResponse, + ReviewCenterItemResponse, + ReviewCenterResponse, + ReviewStatusUpdateRequest, + WorkspaceResponse, +) + +router = APIRouter() + +CREATOR_ROLES = {"admin", "teacher", "content_admin", "org_admin", "instructor", "super_admin"} +SUPPORTED_PRODUCT_TYPES = { + "course", + "learning_path", + "documentation_pack", + "onboarding_track", + "implementation_playbook", + "knowledge_portal", + "certification", + "resource_pack", +} +SUPPORTED_KNOWLEDGE_SOURCE_TYPES = { + "repository", + "document", + "pdf", + "url", + "transcript", + "upload", + "manual_note", + "lesson_source", +} +SUPPORTED_ARTIFACT_TYPES = { + "outline", + "documentation", + "guide", + "checklist", + "source_map", + "export_bundle", + "assessment_seed", + "lesson_draft", +} +SUPPORTED_ARTIFACT_REVIEW_STATUSES = {"draft", "in_review", "approved", "rejected", "needs_changes"} +SUPPORTED_PRODUCT_REVIEW_STATUSES = {"draft", "in_review", "approved", "published", "archived"} +LEARNER_VISIBLE_PRODUCT_STATUSES = {"approved", "published"} +SUPPORTED_ACCESS_GRANT_TYPES = {"manual", "enrollment", "membership", "purchase", "organization", "invitation"} +SUPPORTED_ACCESS_GRANT_STATUSES = {"active", "pending", "expired", "revoked"} + + +def _visible_workspace_ids(db: Session, current_user: User) -> list[UUID] | None: + if current_user.role in {"admin", "super_admin"}: + return None + + active_status = MembershipStatus.ACTIVE.value + memberships = ( + db.query(OrganizationMembership) + .filter(OrganizationMembership.user_id == current_user.id) + .all() + ) + organization_ids = [ + membership.organization_id + for membership in memberships + if getattr(membership.status, "value", membership.status) == active_status + ] + if not organization_ids: + return [] + + return [ + workspace.id + for workspace in db.query(Workspace.id) + .filter(Workspace.organization_id.in_(organization_ids)) + .all() + ] + + +def _scope_workspace_query(query, db: Session, current_user: User): + visible_workspace_ids = _visible_workspace_ids(db, current_user) + if visible_workspace_ids is None: + return query + if not visible_workspace_ids: + return query.filter(False) + return query.filter(Workspace.id.in_(visible_workspace_ids)) + + +def _scope_platform_query(query, model, db: Session, current_user: User): + visible_workspace_ids = _visible_workspace_ids(db, current_user) + if visible_workspace_ids is None: + return query + if not visible_workspace_ids: + return query.filter(False) + return query.filter(model.workspace_id.in_(visible_workspace_ids)) + + +def _visible_organization_ids(db: Session, current_user: User) -> list[UUID] | None: + if current_user.role in {"admin", "teacher", "super_admin"}: + return None + + visible_workspace_ids = _visible_workspace_ids(db, current_user) + if visible_workspace_ids is None: + return None + if not visible_workspace_ids: + return [] + + return [ + organization_id + for (organization_id,) in db.query(Workspace.organization_id) + .filter(Workspace.id.in_(visible_workspace_ids), Workspace.organization_id.isnot(None)) + .all() + ] + + +def _can_manage_workspace(db: Session, current_user: User, workspace: Workspace) -> bool: + if current_user.role in {"admin", "teacher", "super_admin"}: + return True + if current_user.role in {"content_admin", "org_admin", "instructor"}: + return True + if workspace.organization_id is None: + return False + + membership = ( + db.query(OrganizationMembership) + .filter( + OrganizationMembership.organization_id == workspace.organization_id, + OrganizationMembership.user_id == current_user.id, + ) + .first() + ) + if not membership: + return False + membership_role = getattr(membership.role, "value", membership.role) + membership_status = getattr(membership.status, "value", membership.status) + return ( + membership_role in {"content_admin", "org_admin", "teacher", "instructor"} + and membership_status == MembershipStatus.ACTIVE.value + ) + + +def _require_manage_workspace(db: Session, current_user: User, workspace_id: UUID) -> Workspace: + workspace = _scope_workspace_query( + db.query(Workspace).filter(Workspace.id == workspace_id), + db, + current_user, + ).first() + if not workspace: + raise HTTPException(status_code=404, detail="Workspace not found") + if current_user.role not in CREATOR_ROLES or not _can_manage_workspace(db, current_user, workspace): + raise HTTPException(status_code=403, detail="You do not have permission to manage this workspace.") + return workspace + + +def _get_visible_project(db: Session, current_user: User, project_id: UUID) -> Project: + project = _scope_platform_query( + db.query(Project).filter(Project.id == project_id), + Project, + db, + current_user, + ).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + return project + + +def _require_project_in_workspace( + db: Session, + current_user: User, + workspace_id: UUID, + project_id: UUID, +) -> Project: + _require_manage_workspace(db, current_user, workspace_id) + project = ( + db.query(Project) + .filter(Project.id == project_id, Project.workspace_id == workspace_id) + .first() + ) + if not project: + raise HTTPException(status_code=400, detail="Project does not belong to the selected workspace.") + return project + + +def _get_visible_artifact(db: Session, current_user: User, artifact_id: UUID) -> Artifact: + artifact = _scope_platform_query( + db.query(Artifact).filter(Artifact.id == artifact_id), + Artifact, + db, + current_user, + ).first() + if not artifact: + raise HTTPException(status_code=404, detail="Artifact not found") + return artifact + + +def _get_visible_product(db: Session, current_user: User, product_id: UUID) -> Product: + product = _scope_platform_query( + db.query(Product).filter(Product.id == product_id), + Product, + db, + current_user, + ).first() + if not product: + raise HTTPException(status_code=404, detail="Product not found") + return product + + +def _require_manage_platform_record(db: Session, current_user: User, workspace_id: UUID) -> Workspace: + return _require_manage_workspace(db, current_user, workspace_id) + + +def _scope_access_grant_query(query, db: Session, current_user: User): + visible_workspace_ids = _visible_workspace_ids(db, current_user) + if visible_workspace_ids is None: + return query + if not visible_workspace_ids: + return query.filter(False) + return query.filter(AccessGrant.workspace_id.in_(visible_workspace_ids)) + + +def _grant_is_active(grant: AccessGrant, now: datetime | None = None) -> bool: + now = now or datetime.utcnow() + if grant.status != "active" or grant.revoked_at is not None: + return False + if grant.starts_at is not None and grant.starts_at > now: + return False + if grant.expires_at is not None and grant.expires_at <= now: + return False + return True + + +def _source_coverage_for_artifact(artifact: Artifact) -> str: + if artifact.knowledge_source_id: + return "Linked knowledge source" + if artifact.generation_run_id: + return "Generation run linked; source coverage pending" + return "No source link" + + +def _artifact_required_decision(artifact: Artifact) -> str: + if artifact.status in {"approved", "rejected"}: + return "No decision required" + if artifact.status == "needs_changes": + return "Revise or reject artifact wrapper" + return "Review artifact wrapper" + + +def _product_required_decision(product: Product) -> str: + if product.status == "published": + return "Monitor existing runtime governance" + if product.status == "archived": + return "No decision required" + if product.status == "approved": + return "Confirm existing governance before learner delivery" + return "Review product wrapper" + + +def _review_item_for_artifact(artifact: Artifact) -> ReviewCenterItemResponse: + return ReviewCenterItemResponse( + id=artifact.id, + item_type="artifact", + title=artifact.title, + status=artifact.status, + review_state=artifact.review_state, + owner=None, + source_coverage=_source_coverage_for_artifact(artifact), + readiness="Wrapper review only; not learner-deliverable", + required_decision=_artifact_required_decision(artifact), + blocked=artifact.status in {"rejected", "needs_changes"}, + detail_route=f"/workspace/artifacts/{artifact.id}", + governance_route=None, + updated_at=artifact.updated_at, + ) + + +def _review_item_for_product(product: Product) -> ReviewCenterItemResponse: + linked_runtime = "Course-backed runtime link" if product.course_id else "No runtime course link" + return ReviewCenterItemResponse( + id=product.id, + item_type="product", + title=product.title, + status=product.status, + review_state=product.review_state, + owner=None, + source_coverage=linked_runtime, + readiness="Existing lesson governance remains authoritative", + required_decision=_product_required_decision(product), + blocked=product.status == "archived", + detail_route=f"/workspace/products/{product.id}", + governance_route=( + f"/workspace/product-studio/courses/{product.course_id}/edit" + if product.course_id + else None + ), + updated_at=product.updated_at, + ) + + +def _review_item_for_course(course: Course) -> ReviewCenterItemResponse | None: + readiness = evaluate_course_publish_readiness(course) + if readiness.is_ready and not readiness.warnings: + return None + + issue_count = len(readiness.blocking_issues) + warning_count = len(readiness.warnings) + if issue_count: + status = "blocked" + required_decision = "Resolve lesson governance blockers" + else: + status = "approved" + required_decision = "Review readiness warnings" + + return ReviewCenterItemResponse( + id=course.id, + item_type="lesson_governance", + title=course.title, + status=status, + review_state="existing_governance", + owner=None, + source_coverage=f"{issue_count} blocking issue(s), {warning_count} warning(s)", + readiness="Ready" if readiness.is_ready else "Blocked", + required_decision=required_decision, + blocked=not readiness.is_ready, + detail_route=f"/workspace/product-studio/courses/{course.id}/edit", + governance_route=f"/workspace/product-studio/courses/{course.id}/edit", + updated_at=course.updated_at, + ) + + +def _review_center_course_query(db: Session, current_user: User): + query = db.query(Course).options( + joinedload(Course.units).joinedload(Unit.lessons).joinedload(Lesson.sources), + ) + visible_organization_ids = _visible_organization_ids(db, current_user) + if visible_organization_ids is None: + return query + if not visible_organization_ids: + return query.filter(False) + return query.filter(Course.organization_id.in_(visible_organization_ids)) + + +def _learner_product_from_enrollment( + enrollment: StudentCourse, + product: Product | None, +) -> LearnerProductResponse: + course = enrollment.course + return LearnerProductResponse( + id=product.id if product else course.id, + product_id=product.id if product else None, + course_id=course.id, + title=product.title if product else course.title, + description=(product.description if product else course.description), + product_type=product.product_type if product else "course", + product_status=product.status if product else None, + review_state=product.review_state if product else None, + access_state=product.access_state if product else None, + enrollment_id=enrollment.id, + enrollment_status=enrollment.status, + enrolled_on=enrollment.enrolled_on, + access_grant_id=None, + access_grant_status=None, + is_enrolled=True, + source="product_wrapper" if product else "runtime_course", + learner_visibility="Existing enrollment controls access; lesson governance controls content.", + next_action="Continue governed course runtime", + ) + + +def _learner_product_from_grant(grant: AccessGrant) -> LearnerProductResponse: + product = grant.product + course = product.course + return LearnerProductResponse( + id=product.id, + product_id=product.id, + course_id=course.id if course else None, + title=product.title, + description=product.description or (course.description if course else None), + product_type=product.product_type, + product_status=product.status, + review_state=product.review_state, + access_state=product.access_state, + enrollment_id=None, + enrollment_status=None, + enrolled_on=None, + access_grant_id=grant.id, + access_grant_status=grant.status, + is_enrolled=False, + source="product_wrapper", + learner_visibility="Active access grant permits product-level access; lesson governance still controls runtime content.", + next_action=( + "Continue governed course runtime" + if course is not None + else "Product access available; learner runtime is not implemented for this product type yet" + ), + ) + + +@router.get("/review-center", response_model=ReviewCenterResponse) +def get_review_center( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + pending_artifacts = [ + _review_item_for_artifact(artifact) + for artifact in _scope_platform_query(db.query(Artifact), Artifact, db, current_user) + .filter(Artifact.status.in_(["draft", "in_review", "needs_changes"])) + .order_by(Artifact.updated_at.desc(), Artifact.created_at.desc()) + .all() + ] + draft_products = [ + _review_item_for_product(product) + for product in _scope_platform_query(db.query(Product), Product, db, current_user) + .filter(Product.status.in_(["draft", "in_review", "approved"])) + .order_by(Product.updated_at.desc(), Product.created_at.desc()) + .all() + ] + lesson_governance_items = [ + item + for item in ( + _review_item_for_course(course) + for course in _review_center_course_query(db, current_user) + .order_by(Course.updated_at.desc(), Course.created_at.desc()) + .all() + ) + if item is not None + ] + + return ReviewCenterResponse( + pending_artifacts=pending_artifacts, + draft_products=draft_products, + lesson_governance_items=lesson_governance_items, + recent_activity=[ + ReviewCenterActivityResponse( + id="review-activity-placeholder", + message="Review activity history will appear here after audit records are introduced.", + created_at=None, + ) + ], + ) + + +@router.patch("/artifacts/{artifact_id}/review-status", response_model=ArtifactResponse) +def update_artifact_review_status( + artifact_id: UUID, + payload: ReviewStatusUpdateRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + if payload.status not in SUPPORTED_ARTIFACT_REVIEW_STATUSES: + raise HTTPException(status_code=400, detail="Unsupported artifact review status.") + + artifact = _get_visible_artifact(db, current_user, artifact_id) + _require_manage_platform_record(db, current_user, artifact.workspace_id) + + artifact.status = payload.status + artifact.review_state = payload.status + db.commit() + db.refresh(artifact) + return artifact + + +@router.patch("/products/{product_id}/review-status", response_model=ProductResponse) +def update_product_review_status( + product_id: UUID, + payload: ReviewStatusUpdateRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + if payload.status not in SUPPORTED_PRODUCT_REVIEW_STATUSES: + raise HTTPException(status_code=400, detail="Unsupported product review status.") + + product = _get_visible_product(db, current_user, product_id) + _require_manage_platform_record(db, current_user, product.workspace_id) + + product.status = payload.status + product.review_state = payload.status + db.commit() + db.refresh(product) + return product + + +@router.get("/learner-portal/products", response_model=list[LearnerProductResponse]) +def list_learner_products( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + enrolled_courses = ( + db.query(StudentCourse) + .options(joinedload(StudentCourse.course)) + .filter(StudentCourse.student_id == current_user.id) + .order_by(StudentCourse.enrolled_on.desc(), StudentCourse.id.asc()) + .all() + ) + enrolled_course_ids = [enrollment.course_id for enrollment in enrolled_courses] + products_by_course_id = { + product.course_id: product + for product in db.query(Product) + .filter(Product.course_id.in_(enrolled_course_ids)) + .all() + if product.course_id is not None + } if enrolled_course_ids else {} + + learner_products = [ + _learner_product_from_enrollment( + enrollment, + products_by_course_id.get(enrollment.course_id), + ) + for enrollment in enrolled_courses + if enrollment.course is not None + ] + + granted_products_query = ( + db.query(AccessGrant) + .options(joinedload(AccessGrant.product).joinedload(Product.course)) + .join(Product, AccessGrant.product_id == Product.id) + .filter( + AccessGrant.user_id == current_user.id, + AccessGrant.status == "active", + AccessGrant.revoked_at.is_(None), + Product.status.in_(LEARNER_VISIBLE_PRODUCT_STATUSES), + ) + .order_by(Product.title.asc()) + ) + if enrolled_course_ids: + granted_products_query = granted_products_query.filter( + (Product.course_id.is_(None)) | (~Product.course_id.in_(enrolled_course_ids)) + ) + + now = datetime.utcnow() + learner_products.extend( + _learner_product_from_grant(grant) + for grant in granted_products_query.all() + if grant.product is not None and _grant_is_active(grant, now) + ) + + return learner_products + + +@router.get("/access-grants", response_model=list[AccessGrantResponse]) +def list_access_grants( + workspace_id: UUID | None = Query(default=None), + product_id: UUID | None = Query(default=None), + user_id: UUID | None = Query(default=None), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + query = _scope_access_grant_query(db.query(AccessGrant), db, current_user) + if current_user.role not in CREATOR_ROLES: + query = query.filter(AccessGrant.user_id == current_user.id) + if workspace_id: + query = query.filter(AccessGrant.workspace_id == workspace_id) + if product_id: + query = query.filter(AccessGrant.product_id == product_id) + if user_id: + query = query.filter(AccessGrant.user_id == user_id) + return query.order_by(AccessGrant.created_at.desc(), AccessGrant.id.asc()).all() + + +@router.post("/access-grants", response_model=AccessGrantResponse, status_code=201) +def create_access_grant( + payload: AccessGrantCreateRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + if payload.grant_type not in SUPPORTED_ACCESS_GRANT_TYPES: + raise HTTPException(status_code=400, detail="Unsupported access grant type.") + if payload.status not in SUPPORTED_ACCESS_GRANT_STATUSES: + raise HTTPException(status_code=400, detail="Unsupported access grant status.") + if payload.status == "revoked": + raise HTTPException(status_code=400, detail="Create active or pending grants, then revoke explicitly.") + + product = _get_visible_product(db, current_user, payload.product_id) + _require_manage_platform_record(db, current_user, product.workspace_id) + if payload.workspace_id and payload.workspace_id != product.workspace_id: + raise HTTPException(status_code=400, detail="Access grant workspace must match the product workspace.") + if payload.project_id and payload.project_id != product.project_id: + raise HTTPException(status_code=400, detail="Access grant project must match the product project.") + + user = db.query(User).filter(User.id == payload.user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + existing = ( + db.query(AccessGrant) + .filter( + AccessGrant.user_id == payload.user_id, + AccessGrant.product_id == payload.product_id, + AccessGrant.grant_type == payload.grant_type, + ) + .first() + ) + if existing: + existing.status = payload.status + existing.source = payload.source + existing.workspace_id = product.workspace_id + existing.project_id = product.project_id + existing.starts_at = payload.starts_at + existing.expires_at = payload.expires_at + existing.revoked_at = None + existing.grant_metadata = payload.metadata + existing.updated_at = datetime.utcnow() + db.commit() + db.refresh(existing) + return existing + + grant = AccessGrant( + user_id=payload.user_id, + product_id=product.id, + workspace_id=product.workspace_id, + project_id=product.project_id, + grant_type=payload.grant_type, + status=payload.status, + source=payload.source, + starts_at=payload.starts_at, + expires_at=payload.expires_at, + grant_metadata=payload.metadata, + ) + db.add(grant) + db.commit() + db.refresh(grant) + return grant + + +@router.patch("/access-grants/{grant_id}/revoke", response_model=AccessGrantResponse) +def revoke_access_grant( + grant_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + grant = _scope_access_grant_query( + db.query(AccessGrant).filter(AccessGrant.id == grant_id), + db, + current_user, + ).first() + if not grant: + raise HTTPException(status_code=404, detail="Access grant not found") + + _require_manage_platform_record(db, current_user, grant.workspace_id) + grant.status = "revoked" + grant.revoked_at = datetime.utcnow() + grant.updated_at = grant.revoked_at + db.commit() + db.refresh(grant) + return grant + + +@router.get("/workspaces", response_model=list[WorkspaceResponse]) +def list_workspaces( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + query = _scope_workspace_query(db.query(Workspace), db, current_user) + return query.order_by(Workspace.created_at.asc(), Workspace.name.asc()).all() + + +@router.get("/workspaces/{workspace_id}", response_model=WorkspaceResponse) +def get_workspace( + workspace_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + workspace = _scope_workspace_query( + db.query(Workspace).filter(Workspace.id == workspace_id), + db, + current_user, + ).first() + if not workspace: + raise HTTPException(status_code=404, detail="Workspace not found") + return workspace + + +@router.get("/projects", response_model=list[ProjectResponse]) +def list_projects( + workspace_id: UUID | None = Query(default=None), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + query = _scope_platform_query(db.query(Project), Project, db, current_user) + if workspace_id: + query = query.filter(Project.workspace_id == workspace_id) + return query.order_by(Project.created_at.asc(), Project.name.asc()).all() + + +@router.get("/projects/{project_id}", response_model=ProjectResponse) +def get_project( + project_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + project = _scope_platform_query( + db.query(Project).filter(Project.id == project_id), + Project, + db, + current_user, + ).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + return project + + +@router.post("/projects", response_model=ProjectResponse, status_code=201) +def create_project( + payload: ProjectCreateRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _require_manage_workspace(db, current_user, payload.workspace_id) + project = Project( + workspace_id=payload.workspace_id, + name=payload.name, + description=payload.description, + status=payload.status, + project_metadata=payload.metadata, + ) + db.add(project) + db.commit() + db.refresh(project) + return project + + +@router.get("/products", response_model=list[ProductResponse]) +def list_products( + workspace_id: UUID | None = Query(default=None), + project_id: UUID | None = Query(default=None), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + query = _scope_platform_query(db.query(Product), Product, db, current_user) + if workspace_id: + query = query.filter(Product.workspace_id == workspace_id) + if project_id: + query = query.filter(Product.project_id == project_id) + return query.order_by(Product.created_at.asc(), Product.title.asc()).all() + + +@router.get("/products/{product_id}", response_model=ProductResponse) +def get_product( + product_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + product = _scope_platform_query( + db.query(Product).filter(Product.id == product_id), + Product, + db, + current_user, + ).first() + if not product: + raise HTTPException(status_code=404, detail="Product not found") + return product + + +@router.post("/products", response_model=ProductResponse, status_code=201) +def create_product( + payload: ProductCreateRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + if payload.product_type not in SUPPORTED_PRODUCT_TYPES: + raise HTTPException(status_code=400, detail="Unsupported product type.") + + _require_manage_workspace(db, current_user, payload.workspace_id) + + if payload.project_id: + _require_project_in_workspace(db, current_user, payload.workspace_id, payload.project_id) + + if payload.course_id: + course = db.query(Course).filter(Course.id == payload.course_id).first() + if not course: + raise HTTPException(status_code=404, detail="Course not found") + existing_product = db.query(Product).filter(Product.course_id == payload.course_id).first() + if existing_product: + return existing_product + + if payload.program_id: + program = db.query(Program).filter(Program.id == payload.program_id).first() + if not program: + raise HTTPException(status_code=404, detail="Program not found") + existing_product = db.query(Product).filter(Product.program_id == payload.program_id).first() + if existing_product: + return existing_product + + product = Product( + workspace_id=payload.workspace_id, + project_id=payload.project_id, + course_id=payload.course_id, + program_id=payload.program_id, + product_type=payload.product_type, + title=payload.title, + description=payload.description, + status=payload.status, + review_state=payload.review_state, + access_state=payload.access_state, + product_metadata=payload.metadata, + ) + db.add(product) + db.commit() + db.refresh(product) + return product + + +@router.get("/projects/{project_id}/products", response_model=list[ProductResponse]) +def list_project_products( + project_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _get_visible_project(db, current_user, project_id) + return ( + _scope_platform_query(db.query(Product), Product, db, current_user) + .filter(Product.project_id == project_id) + .order_by(Product.created_at.asc(), Product.title.asc()) + .all() + ) + + +@router.get("/knowledge-sources", response_model=list[KnowledgeSourceResponse]) +def list_knowledge_sources( + project_id: UUID | None = Query(default=None), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + query = _scope_platform_query(db.query(KnowledgeSource), KnowledgeSource, db, current_user) + if project_id: + query = query.filter(KnowledgeSource.project_id == project_id) + return query.order_by(KnowledgeSource.created_at.asc(), KnowledgeSource.title.asc()).all() + + +@router.get("/projects/{project_id}/knowledge-sources", response_model=list[KnowledgeSourceResponse]) +def list_project_knowledge_sources( + project_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _get_visible_project(db, current_user, project_id) + return ( + _scope_platform_query(db.query(KnowledgeSource), KnowledgeSource, db, current_user) + .filter(KnowledgeSource.project_id == project_id) + .order_by(KnowledgeSource.created_at.asc(), KnowledgeSource.title.asc()) + .all() + ) + + +@router.post("/knowledge-sources", response_model=KnowledgeSourceResponse, status_code=201) +def create_knowledge_source( + payload: KnowledgeSourceCreateRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + if payload.source_type not in SUPPORTED_KNOWLEDGE_SOURCE_TYPES: + raise HTTPException(status_code=400, detail="Unsupported knowledge source type.") + + _require_project_in_workspace(db, current_user, payload.workspace_id, payload.project_id) + + if payload.source_id: + source = db.query(Source).filter(Source.id == payload.source_id).first() + if not source: + raise HTTPException(status_code=404, detail="Source not found") + + knowledge_source = KnowledgeSource( + workspace_id=payload.workspace_id, + project_id=payload.project_id, + source_id=payload.source_id, + title=payload.title, + source_type=payload.source_type, + uri=payload.uri, + citation=payload.citation, + content_hash=payload.content_hash, + status=payload.status, + source_metadata=payload.metadata, + ) + db.add(knowledge_source) + db.commit() + db.refresh(knowledge_source) + return knowledge_source + + +@router.get("/artifacts", response_model=list[ArtifactResponse]) +def list_artifacts( + project_id: UUID | None = Query(default=None), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + query = _scope_platform_query(db.query(Artifact), Artifact, db, current_user) + if project_id: + query = query.filter(Artifact.project_id == project_id) + return query.order_by(Artifact.created_at.asc(), Artifact.title.asc()).all() + + +@router.get("/projects/{project_id}/artifacts", response_model=list[ArtifactResponse]) +def list_project_artifacts( + project_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _get_visible_project(db, current_user, project_id) + return ( + _scope_platform_query(db.query(Artifact), Artifact, db, current_user) + .filter(Artifact.project_id == project_id) + .order_by(Artifact.created_at.asc(), Artifact.title.asc()) + .all() + ) + + +@router.get("/artifacts/{artifact_id}", response_model=ArtifactResponse) +def get_artifact( + artifact_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + artifact = _scope_platform_query( + db.query(Artifact).filter(Artifact.id == artifact_id), + Artifact, + db, + current_user, + ).first() + if not artifact: + raise HTTPException(status_code=404, detail="Artifact not found") + return artifact + + +@router.post("/artifacts", response_model=ArtifactResponse, status_code=201) +def create_artifact( + payload: ArtifactCreateRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + if payload.artifact_type not in SUPPORTED_ARTIFACT_TYPES: + raise HTTPException(status_code=400, detail="Unsupported artifact type.") + + _require_project_in_workspace(db, current_user, payload.workspace_id, payload.project_id) + + if payload.product_id: + product = ( + db.query(Product) + .filter( + Product.id == payload.product_id, + Product.workspace_id == payload.workspace_id, + Product.project_id == payload.project_id, + ) + .first() + ) + if not product: + raise HTTPException(status_code=400, detail="Product does not belong to the selected project.") + + if payload.knowledge_source_id: + knowledge_source = ( + db.query(KnowledgeSource) + .filter( + KnowledgeSource.id == payload.knowledge_source_id, + KnowledgeSource.workspace_id == payload.workspace_id, + KnowledgeSource.project_id == payload.project_id, + ) + .first() + ) + if not knowledge_source: + raise HTTPException(status_code=400, detail="Knowledge source does not belong to the selected project.") + + if payload.generation_run_id: + generation_run = ( + db.query(GenerationRun) + .filter( + GenerationRun.id == payload.generation_run_id, + GenerationRun.workspace_id == payload.workspace_id, + GenerationRun.project_id == payload.project_id, + ) + .first() + ) + if not generation_run: + raise HTTPException(status_code=400, detail="Generation run does not belong to the selected project.") + + artifact = Artifact( + workspace_id=payload.workspace_id, + project_id=payload.project_id, + product_id=payload.product_id, + generation_run_id=payload.generation_run_id, + knowledge_source_id=payload.knowledge_source_id, + artifact_type=payload.artifact_type, + title=payload.title, + body=payload.body, + uri=payload.uri, + status=payload.status, + review_state=payload.review_state, + artifact_metadata=payload.metadata, + ) + db.add(artifact) + db.commit() + db.refresh(artifact) + return artifact + + +@router.get("/generation-runs", response_model=list[GenerationRunResponse]) +def list_generation_runs( + project_id: UUID | None = Query(default=None), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + query = _scope_platform_query(db.query(GenerationRun), GenerationRun, db, current_user) + if project_id: + query = query.filter(GenerationRun.project_id == project_id) + return query.order_by(GenerationRun.created_at.desc()).all() + + +@router.get("/projects/{project_id}/generation-runs", response_model=list[GenerationRunResponse]) +def list_project_generation_runs( + project_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _get_visible_project(db, current_user, project_id) + return ( + _scope_platform_query(db.query(GenerationRun), GenerationRun, db, current_user) + .filter(GenerationRun.project_id == project_id) + .order_by(GenerationRun.created_at.desc()) + .all() + ) + + +@router.get("/generation-runs/{generation_run_id}", response_model=GenerationRunResponse) +def get_generation_run( + generation_run_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + generation_run = _scope_platform_query( + db.query(GenerationRun).filter(GenerationRun.id == generation_run_id), + GenerationRun, + db, + current_user, + ).first() + if not generation_run: + raise HTTPException(status_code=404, detail="Generation run not found") + return generation_run diff --git a/backend/app/crud/progress.py b/backend/app/crud/progress.py index 1cd5532..aef420b 100644 --- a/backend/app/crud/progress.py +++ b/backend/app/crud/progress.py @@ -125,16 +125,17 @@ def update_segment_progress_status(db: Session, progress_id: UUID, new_status: s if unit_progress: unit = db.get(Unit, unit_progress.unit_id) if unit_progress else None governed_selection = governed_lessons_for_unit(unit) if unit else None + governed_delivery_active = ( + governed_selection is not None + and governed_selection.state == GOVERNED_AVAILABLE + ) all_segments = ( db.query(SegmentProgress) .filter(SegmentProgress.student_unit_id == unit_progress.id) .all() ) relevant_segments = all_segments - if ( - governed_selection is not None - and governed_selection.state == GOVERNED_AVAILABLE - ): + if governed_delivery_active: governed_lesson_ids = { lesson.id for lesson in governed_selection.lessons } @@ -205,47 +206,47 @@ def update_segment_progress_status(db: Session, progress_id: UUID, new_status: s next_unit = None if next_unit: - existing_next = ( - db.query(StudentUnitProgress) - .filter( - StudentUnitProgress.student_course_id == student_course.id, - StudentUnitProgress.unit_id == next_unit.id, - ) - .first() - ) - - if not existing_next: - new_progress = create_student_unit_progress( - db, - student_course.id, - next_unit.id, - ) - - new_progress.status = ProgressStatus.IN_PROGRESS - new_progress.started_at = datetime.utcnow() - new_progress.last_updated = datetime.utcnow() - next_unit_lessons = ( - db.query(Lesson) - .filter(Lesson.unit_id == next_unit.id) - .order_by(Lesson.order.asc()) - .all() + if not governed_delivery_active: + existing_next = ( + db.query(StudentUnitProgress) + .filter( + StudentUnitProgress.student_course_id == student_course.id, + StudentUnitProgress.unit_id == next_unit.id, + ) + .first() ) - for lesson in next_unit_lessons: - seg = create_segment_progress( + if not existing_next: + new_progress = create_student_unit_progress( db, - new_progress.id, - lesson.id, + student_course.id, + next_unit.id, ) - # First lesson becomes active immediately - if lesson == next_unit_lessons[0]: - seg.status = ProgressStatus.IN_PROGRESS - seg.started_at = datetime.utcnow() - seg.last_updated = datetime.utcnow() + new_progress.status = ProgressStatus.IN_PROGRESS + new_progress.started_at = datetime.utcnow() + new_progress.last_updated = datetime.utcnow() + next_unit_lessons = ( + db.query(Lesson) + .filter(Lesson.unit_id == next_unit.id) + .order_by(Lesson.order.asc()) + .all() + ) + + for lesson in next_unit_lessons: + seg = create_segment_progress( + db, + new_progress.id, + lesson.id, + ) + + # First lesson becomes active immediately + if lesson == next_unit_lessons[0]: + seg.status = ProgressStatus.IN_PROGRESS + seg.started_at = datetime.utcnow() + seg.last_updated = datetime.utcnow() - - db.commit() + db.commit() else: student_course.status = "completed" @@ -379,6 +380,81 @@ def _governed_current_segment_for_unit_progress( return segments[0] if segments else None +def ensure_current_segment_progress_for_unit( + db: Session, unit_progress: StudentUnitProgress +) -> SegmentProgress | None: + unit = db.get(Unit, unit_progress.unit_id) + if unit is None: + return None + + selection = governed_lessons_for_unit(unit) + if selection.state != GOVERNED_AVAILABLE: + return None + + governed_segments = _ensure_governed_segments(db, unit_progress, selection.lessons) + active_segment = next( + (segment for segment in governed_segments if segment.status == ProgressStatus.IN_PROGRESS), + None, + ) + if active_segment is not None: + return active_segment + + next_segment = next( + ( + segment + for segment in governed_segments + if segment.status not in {ProgressStatus.COMPLETED, ProgressStatus.SKIPPED} + ), + None, + ) + if next_segment is None: + return None + + if next_segment.status == ProgressStatus.NOT_STARTED: + student_course = db.get(StudentCourse, unit_progress.student_course_id) + next_segment.status = ProgressStatus.IN_PROGRESS + next_segment.started_at = next_segment.started_at or datetime.utcnow() + next_segment.last_updated = datetime.utcnow() + unit_progress.status = ProgressStatus.IN_PROGRESS + unit_progress.started_at = unit_progress.started_at or datetime.utcnow() + unit_progress.last_updated = datetime.utcnow() + if student_course is not None: + student_course.last_activity_at = datetime.utcnow() + db.commit() + db.refresh(next_segment) + db.refresh(unit_progress) + + return next_segment + + +def resolve_governed_segment_for_unit_progress( + db: Session, unit_progress_id: UUID | str +) -> dict[str, object] | None: + unit_progress = db.get(StudentUnitProgress, unit_progress_id) + if unit_progress is None: + return None + + unit = db.get(Unit, unit_progress.unit_id) + if unit is None: + return None + + selection = governed_lessons_for_unit(unit) + if selection.state != GOVERNED_AVAILABLE: + return None + + segment = ensure_current_segment_progress_for_unit(db, unit_progress) + if segment is None: + return None + + return { + "delivery_state": GOVERNED_AVAILABLE, + "detail": selection.detail, + "lesson_id": segment.lesson_id, + "status": segment.status, + "unit_progress_id": unit_progress.id, + } + + def resolve_governed_progression( db: Session, student_course_id: UUID | str ) -> dict[str, object]: diff --git a/backend/app/main.py b/backend/app/main.py index dd8b056..c544c6a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -29,6 +29,7 @@ uploads, users, meta, + v2_platform, ) app = FastAPI() @@ -81,6 +82,7 @@ app.include_router(assignments.router, prefix="/api", tags=["Assignments"]) app.include_router(uploads.router, prefix="/api", tags=["Uploads"]) app.include_router(meta.router, prefix="/api", tags=["Meta"]) +app.include_router(v2_platform.router, prefix="/api", tags=["V2 Platform"]) @app.get("/api") diff --git a/backend/app/models.py b/backend/app/models.py index 7f3a050..b280c36 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -81,6 +81,7 @@ class User(Base): back_populates="student", cascade="all, delete-orphan", ) + access_grants = relationship("AccessGrant", back_populates="user") class Organization(Base): @@ -116,6 +117,7 @@ class Organization(Base): courses = relationship("Course", back_populates="organization") sections = relationship("Section", back_populates="organization") programs = relationship("Program", back_populates="organization") + workspaces = relationship("Workspace", back_populates="organization") class OrganizationMembership(Base): @@ -230,6 +232,7 @@ class Course(Base): cascade="all, delete-orphan", ) assessments = relationship("Assessment", back_populates="course") + products = relationship("Product", back_populates="course") class CourseVersion(Base): @@ -340,6 +343,7 @@ class Source(Base): created_at = Column(DateTime, default=datetime.utcnow) lesson = relationship("Lesson", back_populates="sources") + knowledge_sources = relationship("KnowledgeSource", back_populates="source") class Activity(Base): @@ -507,6 +511,185 @@ class Program(Base): back_populates="program", cascade="all, delete-orphan", ) + products = relationship("Product", back_populates="program") + + +class Workspace(Base): + __tablename__ = "workspaces" + __table_args__ = ( + UniqueConstraint("organization_id", name="uq_workspace_organization"), + ) + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=True) + name = Column(String, nullable=False) + slug = Column(String, nullable=True, unique=True, index=True) + description = Column(Text, nullable=True) + status = Column(String, nullable=False, default="active") + workspace_metadata = Column("metadata", JSON, nullable=True, default=dict) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + organization = relationship("Organization", back_populates="workspaces") + projects = relationship("Project", back_populates="workspace", cascade="all, delete-orphan") + products = relationship("Product", back_populates="workspace", cascade="all, delete-orphan") + knowledge_sources = relationship("KnowledgeSource", back_populates="workspace", cascade="all, delete-orphan") + artifacts = relationship("Artifact", back_populates="workspace", cascade="all, delete-orphan") + generation_runs = relationship("GenerationRun", back_populates="workspace", cascade="all, delete-orphan") + access_grants = relationship("AccessGrant", back_populates="workspace", cascade="all, delete-orphan") + + +class Project(Base): + __tablename__ = "projects" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True) + workspace_id = Column(UUID(as_uuid=True), ForeignKey("workspaces.id"), nullable=False) + name = Column(String, nullable=False) + description = Column(Text, nullable=True) + status = Column(String, nullable=False, default="active") + project_metadata = Column("metadata", JSON, nullable=True, default=dict) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + workspace = relationship("Workspace", back_populates="projects") + products = relationship("Product", back_populates="project") + knowledge_sources = relationship("KnowledgeSource", back_populates="project", cascade="all, delete-orphan") + artifacts = relationship("Artifact", back_populates="project", cascade="all, delete-orphan") + generation_runs = relationship("GenerationRun", back_populates="project", cascade="all, delete-orphan") + access_grants = relationship("AccessGrant", back_populates="project") + + +class Product(Base): + __tablename__ = "products" + __table_args__ = ( + UniqueConstraint("course_id", name="uq_product_course"), + UniqueConstraint("program_id", name="uq_product_program"), + ) + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True) + workspace_id = Column(UUID(as_uuid=True), ForeignKey("workspaces.id"), nullable=False) + project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=True) + course_id = Column(UUID(as_uuid=True), ForeignKey("courses.id"), nullable=True) + program_id = Column(UUID(as_uuid=True), ForeignKey("programs.id"), nullable=True) + product_type = Column(String, nullable=False, default="course") + title = Column(String, nullable=False) + description = Column(Text, nullable=True) + status = Column(String, nullable=False, default="draft") + review_state = Column(String, nullable=False, default="not_reviewed") + access_state = Column(String, nullable=False, default="private") + product_metadata = Column("metadata", JSON, nullable=True, default=dict) + published_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + workspace = relationship("Workspace", back_populates="products") + project = relationship("Project", back_populates="products") + course = relationship("Course", back_populates="products") + program = relationship("Program", back_populates="products") + artifacts = relationship("Artifact", back_populates="product") + generation_runs = relationship("GenerationRun", back_populates="product") + access_grants = relationship("AccessGrant", back_populates="product", cascade="all, delete-orphan") + + +class AccessGrant(Base): + __tablename__ = "access_grants" + __table_args__ = ( + UniqueConstraint("user_id", "product_id", "grant_type", name="uq_access_grant_user_product_type"), + ) + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + product_id = Column(UUID(as_uuid=True), ForeignKey("products.id"), nullable=False) + workspace_id = Column(UUID(as_uuid=True), ForeignKey("workspaces.id"), nullable=False) + project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=True) + grant_type = Column(String, nullable=False, default="manual") + status = Column(String, nullable=False, default="active") + source = Column(String, nullable=False, default="manual") + starts_at = Column(DateTime, nullable=True) + expires_at = Column(DateTime, nullable=True) + revoked_at = Column(DateTime, nullable=True) + grant_metadata = Column("metadata", JSON, nullable=True, default=dict) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + user = relationship("User", back_populates="access_grants") + product = relationship("Product", back_populates="access_grants") + workspace = relationship("Workspace", back_populates="access_grants") + project = relationship("Project", back_populates="access_grants") + + +class KnowledgeSource(Base): + __tablename__ = "knowledge_sources" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True) + workspace_id = Column(UUID(as_uuid=True), ForeignKey("workspaces.id"), nullable=False) + project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False) + source_id = Column(UUID(as_uuid=True), ForeignKey("sources.id"), nullable=True) + title = Column(String, nullable=False) + source_type = Column(String, nullable=False, default="document") + uri = Column(String, nullable=True) + citation = Column(Text, nullable=True) + content_hash = Column(String, nullable=True) + status = Column(String, nullable=False, default="available") + source_metadata = Column("metadata", JSON, nullable=True, default=dict) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + workspace = relationship("Workspace", back_populates="knowledge_sources") + project = relationship("Project", back_populates="knowledge_sources") + source = relationship("Source", back_populates="knowledge_sources") + artifacts = relationship("Artifact", back_populates="knowledge_source") + + +class GenerationRun(Base): + __tablename__ = "generation_runs" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True) + workspace_id = Column(UUID(as_uuid=True), ForeignKey("workspaces.id"), nullable=False) + project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False) + product_id = Column(UUID(as_uuid=True), ForeignKey("products.id"), nullable=True) + status = Column(String, nullable=False, default="queued") + provider = Column(String, nullable=True) + model_name = Column(String, nullable=True) + prompt = Column(Text, nullable=True) + output_summary = Column(Text, nullable=True) + input_metadata = Column(JSON, nullable=True, default=dict) + output_metadata = Column(JSON, nullable=True, default=dict) + error_message = Column(Text, nullable=True) + started_at = Column(DateTime, nullable=True) + completed_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + + workspace = relationship("Workspace", back_populates="generation_runs") + project = relationship("Project", back_populates="generation_runs") + product = relationship("Product", back_populates="generation_runs") + artifacts = relationship("Artifact", back_populates="generation_run") + + +class Artifact(Base): + __tablename__ = "artifacts" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True) + workspace_id = Column(UUID(as_uuid=True), ForeignKey("workspaces.id"), nullable=False) + project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False) + product_id = Column(UUID(as_uuid=True), ForeignKey("products.id"), nullable=True) + generation_run_id = Column(UUID(as_uuid=True), ForeignKey("generation_runs.id"), nullable=True) + knowledge_source_id = Column(UUID(as_uuid=True), ForeignKey("knowledge_sources.id"), nullable=True) + artifact_type = Column(String, nullable=False, default="documentation") + title = Column(String, nullable=False) + body = Column(Text, nullable=True) + uri = Column(String, nullable=True) + status = Column(String, nullable=False, default="draft") + review_state = Column(String, nullable=False, default="review_required") + artifact_metadata = Column("metadata", JSON, nullable=True, default=dict) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + workspace = relationship("Workspace", back_populates="artifacts") + project = relationship("Project", back_populates="artifacts") + product = relationship("Product", back_populates="artifacts") + generation_run = relationship("GenerationRun", back_populates="artifacts") + knowledge_source = relationship("KnowledgeSource", back_populates="artifacts") class ProgramCourse(Base): diff --git a/backend/app/schemas.py b/backend/app/schemas.py index c265d8e..969d9cc 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -87,6 +87,254 @@ class Config: from_attributes = True +class WorkspaceResponse(BaseModel): + id: UUID + organization_id: Optional[UUID] = None + name: str + slug: Optional[str] = None + description: Optional[str] = None + status: str + metadata: dict[str, Any] = Field(default_factory=dict, validation_alias="workspace_metadata") + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class ProjectResponse(BaseModel): + id: UUID + workspace_id: UUID + name: str + description: Optional[str] = None + status: str + metadata: dict[str, Any] = Field(default_factory=dict, validation_alias="project_metadata") + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class ProjectCreateRequest(BaseModel): + workspace_id: UUID + name: str + description: Optional[str] = None + status: str = "active" + metadata: dict[str, Any] = Field(default_factory=dict) + + +class ProductResponse(BaseModel): + id: UUID + workspace_id: UUID + project_id: Optional[UUID] = None + course_id: Optional[UUID] = None + program_id: Optional[UUID] = None + product_type: str + title: str + description: Optional[str] = None + status: str + review_state: str + access_state: str + metadata: dict[str, Any] = Field(default_factory=dict, validation_alias="product_metadata") + published_at: Optional[datetime] = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class ProductCreateRequest(BaseModel): + workspace_id: UUID + project_id: Optional[UUID] = None + course_id: Optional[UUID] = None + program_id: Optional[UUID] = None + product_type: str + title: str + description: Optional[str] = None + status: str = "draft" + review_state: str = "not_reviewed" + access_state: str = "private" + metadata: dict[str, Any] = Field(default_factory=dict) + + +class AccessGrantResponse(BaseModel): + id: UUID + user_id: UUID + product_id: UUID + workspace_id: UUID + project_id: Optional[UUID] = None + grant_type: str + status: str + source: str + starts_at: Optional[datetime] = None + expires_at: Optional[datetime] = None + revoked_at: Optional[datetime] = None + metadata: dict[str, Any] = Field(default_factory=dict, validation_alias="grant_metadata") + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class AccessGrantCreateRequest(BaseModel): + user_id: UUID + product_id: UUID + workspace_id: Optional[UUID] = None + project_id: Optional[UUID] = None + grant_type: str = "manual" + status: str = "active" + source: str = "manual" + starts_at: Optional[datetime] = None + expires_at: Optional[datetime] = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +class KnowledgeSourceResponse(BaseModel): + id: UUID + workspace_id: UUID + project_id: UUID + source_id: Optional[UUID] = None + title: str + source_type: str + uri: Optional[str] = None + citation: Optional[str] = None + content_hash: Optional[str] = None + status: str + metadata: dict[str, Any] = Field(default_factory=dict, validation_alias="source_metadata") + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class KnowledgeSourceCreateRequest(BaseModel): + workspace_id: UUID + project_id: UUID + source_id: Optional[UUID] = None + title: str + source_type: str + uri: Optional[str] = None + citation: Optional[str] = None + content_hash: Optional[str] = None + status: str = "available" + metadata: dict[str, Any] = Field(default_factory=dict) + + +class ArtifactResponse(BaseModel): + id: UUID + workspace_id: UUID + project_id: UUID + product_id: Optional[UUID] = None + generation_run_id: Optional[UUID] = None + knowledge_source_id: Optional[UUID] = None + artifact_type: str + title: str + body: Optional[str] = None + uri: Optional[str] = None + status: str + review_state: str + metadata: dict[str, Any] = Field(default_factory=dict, validation_alias="artifact_metadata") + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class ArtifactCreateRequest(BaseModel): + workspace_id: UUID + project_id: UUID + product_id: Optional[UUID] = None + generation_run_id: Optional[UUID] = None + knowledge_source_id: Optional[UUID] = None + artifact_type: str + title: str + body: Optional[str] = None + uri: Optional[str] = None + status: str = "draft" + review_state: str = "review_required" + metadata: dict[str, Any] = Field(default_factory=dict) + + +class ReviewStatusUpdateRequest(BaseModel): + status: str + + +class ReviewCenterItemResponse(BaseModel): + id: UUID + item_type: str + title: str + status: str + review_state: Optional[str] = None + owner: Optional[str] = None + source_coverage: str + readiness: str + required_decision: str + blocked: bool = False + detail_route: Optional[str] = None + governance_route: Optional[str] = None + updated_at: Optional[datetime] = None + + +class ReviewCenterActivityResponse(BaseModel): + id: str + message: str + created_at: Optional[datetime] = None + + +class ReviewCenterResponse(BaseModel): + pending_artifacts: list[ReviewCenterItemResponse] + draft_products: list[ReviewCenterItemResponse] + lesson_governance_items: list[ReviewCenterItemResponse] + recent_activity: list[ReviewCenterActivityResponse] + + +class LearnerProductResponse(BaseModel): + id: UUID + product_id: Optional[UUID] = None + course_id: Optional[UUID] = None + title: str + description: Optional[str] = None + product_type: str = "course" + product_status: Optional[str] = None + review_state: Optional[str] = None + access_state: Optional[str] = None + enrollment_id: Optional[UUID] = None + enrollment_status: Optional[str] = None + enrolled_on: Optional[datetime] = None + access_grant_id: Optional[UUID] = None + access_grant_status: Optional[str] = None + is_enrolled: bool = False + source: str + learner_visibility: str + next_action: str + + +class GenerationRunResponse(BaseModel): + id: UUID + workspace_id: UUID + project_id: UUID + product_id: Optional[UUID] = None + status: str + provider: Optional[str] = None + model_name: Optional[str] = None + prompt: Optional[str] = None + output_summary: Optional[str] = None + input_metadata: dict[str, Any] = Field(default_factory=dict) + output_metadata: dict[str, Any] = Field(default_factory=dict) + error_message: Optional[str] = None + started_at: Optional[datetime] = None + completed_at: Optional[datetime] = None + created_at: datetime + + class Config: + from_attributes = True + + class OrganizationInviteCreate(BaseModel): email: str role: str @@ -421,7 +669,7 @@ class CourseResponse(RevisionMetadataResponseMixin): title: str description: str learning_objectives: Optional[str] = None - skill_tags: List[str] = Field(default_factory=list) + skill_tags: list[str] = Field(default_factory=list) standards_metadata: dict[str, Any] = Field(default_factory=dict) units: List[UnitResponse] = Field(default_factory=list) diff --git a/backend/tests/test_course_permissions.py b/backend/tests/test_course_permissions.py index 6b7b099..7cf0c2b 100644 --- a/backend/tests/test_course_permissions.py +++ b/backend/tests/test_course_permissions.py @@ -135,3 +135,32 @@ def test_student_cannot_delete_course(test_db, student_user): app.dependency_overrides = {} + +def test_course_reads_normalize_null_metadata_fields(test_db, teacher_user): + course = Course( + id=uuid.uuid4(), + title="Nullable Metadata", + description="Desc", + skill_tags=None, + standards_metadata=None, + ) + test_db.add(course) + test_db.commit() + + app.dependency_overrides[get_current_user] = lambda: teacher_user + + list_response = client.get("/api/courses") + assert list_response.status_code == 200 + list_payload = list_response.json() + matching = next(item for item in list_payload if item["id"] == str(course.id)) + assert matching["skill_tags"] == [] + assert matching["standards_metadata"] == {} + + detail_response = client.get(f"/api/courses/{course.id}") + assert detail_response.status_code == 200 + detail_payload = detail_response.json() + assert detail_payload["skill_tags"] == [] + assert detail_payload["standards_metadata"] == {} + + app.dependency_overrides = {} + diff --git a/backend/tests/test_seed_demo_auth.py b/backend/tests/test_seed_demo_auth.py index 335ab09..9405755 100644 --- a/backend/tests/test_seed_demo_auth.py +++ b/backend/tests/test_seed_demo_auth.py @@ -57,6 +57,42 @@ def test_seed_demo_is_idempotent_and_resets_passwords(db_engine, monkeypatch): session.close() +def test_seed_demo_reseed_restores_demo_identity_by_username_and_email(db_engine, monkeypatch): + session_factory = _build_session_factory(db_engine) + + _seed_with_factory(monkeypatch, session_factory) + + session = session_factory() + try: + student = session.query(User).filter(User.username == "student1").one() + teacher = session.query(User).filter(User.username == "teacher").one() + student.username = "legacy_student1" + teacher.email = "legacy-teacher@example.com" + session.commit() + finally: + session.close() + + _seed_with_factory(monkeypatch, session_factory) + + session = session_factory() + try: + restored_student = session.query(User).filter(User.username == "student1").one() + restored_teacher = session.query(User).filter(User.username == "teacher").one() + + assert restored_student.email == seed_demo.DEMO_USERS["student1"]["email"] + assert restored_student.firstname == seed_demo.DEMO_USERS["student1"]["firstname"] + assert verify_password(seed_demo.DEMO_PASSWORD, restored_student.hashed_password) + + assert restored_teacher.email == seed_demo.DEMO_USERS["teacher"]["email"] + assert restored_teacher.firstname == seed_demo.DEMO_USERS["teacher"]["firstname"] + assert verify_password(seed_demo.DEMO_PASSWORD, restored_teacher.hashed_password) + + assert session.query(User).filter(User.username == "legacy_student1").count() == 0 + assert session.query(User).filter(User.email == "legacy-teacher@example.com").count() == 0 + finally: + session.close() + + def test_seed_demo_creates_governed_ordered_student_path(db_engine, monkeypatch): session_factory = _build_session_factory(db_engine) _seed_with_factory(monkeypatch, session_factory) diff --git a/backend/tests/test_start_course_endpoint.py b/backend/tests/test_start_course_endpoint.py index dd9e1f9..d0f1b77 100644 --- a/backend/tests/test_start_course_endpoint.py +++ b/backend/tests/test_start_course_endpoint.py @@ -4,7 +4,7 @@ from fastapi import FastAPI from fastapi.testclient import TestClient -from app.api.routes import start_course +from app.api.routes import progress, start_course from app.auth import get_current_user from app.crud import progress as progress_crud from app.database import get_db @@ -73,6 +73,7 @@ def override_get_current_user(user): def _build_test_client(db_session, user): test_app = FastAPI() test_app.include_router(start_course.router, prefix="/api") + test_app.include_router(progress.router, prefix="/api/progress") test_app.dependency_overrides[get_current_user] = override_get_current_user(user) def override_get_db(): @@ -395,3 +396,176 @@ def test_completion_does_not_advance_to_stale_non_governed_segment(db_session, t assert stale_draft_segment.status == ProgressStatus.NOT_STARTED assert unit_progress.status == ProgressStatus.COMPLETED assert student_course.status == "completed" + + +def test_completed_governed_unit_returns_pending_review_when_next_unit_has_no_approved_lessons( + db_session, test_student +): + course = Course(id=uuid.uuid4(), title="Course F", description="Governed block after completion") + unit1 = Unit(id=uuid.uuid4(), title="Unit 1", course_id=course.id, order=1) + unit2 = Unit(id=uuid.uuid4(), title="Unit 2", course_id=course.id, order=2) + lesson1 = Lesson( + id=uuid.uuid4(), + title="Approved Lesson", + unit_id=unit1.id, + duration_minutes=5, + order=1, + objective="Objective", + learning_objectives="Learning objective", + key_concepts=["concept"], + hook="Hook", + content="Content", + guided_practice="Guided", + independent_practice="Independent", + assessment="Assessment", + review_status="approved", + ) + reviewed_lesson = Lesson( + id=uuid.uuid4(), + title="Reviewed Lesson", + unit_id=unit2.id, + duration_minutes=5, + order=1, + objective="Objective", + learning_objectives="Learning objective", + key_concepts=["concept"], + hook="Hook", + content="Content", + guided_practice="Guided", + independent_practice="Independent", + assessment="Assessment", + review_status="reviewed", + ) + db_session.add_all([course, unit1, unit2, lesson1, reviewed_lesson]) + db_session.commit() + db_session.add_all([ + Source(lesson_id=lesson1.id, citation="Source 1", url="https://example.com/1"), + Source(lesson_id=reviewed_lesson.id, citation="Source 2", url="https://example.com/2"), + ]) + db_session.commit() + + student_course = StudentCourse(student_id=test_student.id, course_id=course.id) + db_session.add(student_course) + db_session.commit() + db_session.refresh(student_course) + + start_state = progress_crud.resolve_governed_progression(db_session, student_course.id) + assert start_state["delivery_state"] == "governed_available" + assert start_state["lesson_id"] == lesson1.id + + current_segment = ( + db_session.query(SegmentProgress) + .filter_by( + student_unit_id=start_state["unit_progress_id"], + lesson_id=lesson1.id, + ) + .one() + ) + progress_crud.update_segment_progress_status( + db_session, current_segment.id, ProgressStatus.COMPLETED + ) + + next_state = progress_crud.resolve_governed_progression(db_session, student_course.id) + assert next_state["delivery_state"] == "pending_review" + assert "review" in next_state["detail"].lower() + + blocked_unit_progress = ( + db_session.query(StudentUnitProgress) + .filter_by(student_course_id=student_course.id, unit_id=unit2.id) + .first() + ) + if blocked_unit_progress is not None: + blocked_segments = ( + db_session.query(SegmentProgress) + .filter_by(student_unit_id=blocked_unit_progress.id) + .all() + ) + assert blocked_segments == [] + + +def test_start_course_returns_unit_progress_id_with_segment_endpoint_ready( + db_session, test_student +): + course = Course(id=uuid.uuid4(), title="Course Recovery", description="Test") + unit = Unit(id=uuid.uuid4(), title="Recoverable Unit", course_id=course.id, order=1) + lesson = Lesson( + id=uuid.uuid4(), + title="Approved Lesson", + unit_id=unit.id, + duration_minutes=5, + order=1, + objective="Objective", + learning_objectives="Learning objective", + key_concepts=["concept"], + hook="Hook", + content="Content", + guided_practice="Guided", + independent_practice="Independent", + assessment="Assessment", + review_status="approved", + ) + draft_lesson = Lesson( + id=uuid.uuid4(), + title="Draft Lesson", + unit_id=unit.id, + duration_minutes=5, + order=2, + review_status="draft", + ) + db_session.add_all([course, unit, lesson, draft_lesson]) + db_session.commit() + db_session.add(Source(lesson_id=lesson.id, citation="Source", url="https://example.com/source")) + db_session.commit() + + student_course = StudentCourse(student_id=test_student.id, course_id=course.id) + db_session.add(student_course) + db_session.commit() + db_session.refresh(student_course) + + unit_progress = StudentUnitProgress( + student_course_id=student_course.id, + unit_id=unit.id, + status=ProgressStatus.IN_PROGRESS, + ) + db_session.add(unit_progress) + db_session.commit() + db_session.refresh(unit_progress) + + client = _build_test_client(db_session, test_student) + + start_response = client.post("/api/start-course", json={"course_id": str(course.id)}) + assert start_response.status_code == 200 + start_data = start_response.json() + assert start_data["delivery_state"] == "governed_available" + assert start_data["unit_progress_id"] == str(unit_progress.id) + + db_session.query(SegmentProgress).filter_by(student_unit_id=unit_progress.id).delete() + db_session.commit() + + segment_response = client.get( + f"/api/progress/segment?student_unit_id={unit_progress.id}" + ) + + assert segment_response.status_code == 200 + segment_data = segment_response.json() + assert segment_data["delivery_state"] == "governed_available" + assert segment_data["lesson_id"] == str(lesson.id) + assert segment_data["status"] == ProgressStatus.IN_PROGRESS.value + assert segment_data["unit_progress_id"] == str(unit_progress.id) + + segment_rows = ( + db_session.query(SegmentProgress) + .filter_by(student_unit_id=unit_progress.id) + .order_by(SegmentProgress.lesson_id) + .all() + ) + assert len(segment_rows) == 1 + assert segment_rows[0].lesson_id == lesson.id + assert segment_rows[0].status == ProgressStatus.IN_PROGRESS + + draft_segment = ( + db_session.query(SegmentProgress) + .filter_by(student_unit_id=unit_progress.id, lesson_id=draft_lesson.id) + .first() + ) + assert draft_segment is None diff --git a/backend/tests/test_student_progress.py b/backend/tests/test_student_progress.py index 75e102a..9cd4d71 100644 --- a/backend/tests/test_student_progress.py +++ b/backend/tests/test_student_progress.py @@ -1,7 +1,7 @@ import uuid import pytest from app.models import ( - User, Course, Unit, Lesson, + User, Course, Unit, Lesson, Source, StudentCourse, StudentUnitProgress, SegmentProgress ) from app.crud import progress as crud @@ -305,3 +305,62 @@ def test_complete_single_lesson_course_marks_course_completed(db_session, test_u updated_course = db_session.get(StudentCourse, student_course.id) assert updated_course.status == "completed" + + +def test_resolve_governed_progression_recreates_missing_segments_for_available_unit( + db_session, test_user +): + course = Course(id=uuid.uuid4(), title="Governed Recovery", description="Recovery path") + unit = Unit(id=uuid.uuid4(), title="Recoverable Unit", course_id=course.id, order=1) + lesson = Lesson( + id=uuid.uuid4(), + title="Recoverable Lesson", + unit_id=unit.id, + duration_minutes=5, + order=1, + objective="Objective", + learning_objectives="Learning objective", + key_concepts=["concept"], + hook="Hook", + content="Content", + guided_practice="Guided", + independent_practice="Independent", + assessment="Assessment", + review_status="approved", + ) + db_session.add_all([course, unit, lesson]) + db_session.commit() + db_session.add( + Source(lesson_id=lesson.id, citation="Source", url="https://example.com/source") + ) + db_session.commit() + + student_course = StudentCourse(student_id=test_user.id, course_id=course.id) + db_session.add(student_course) + db_session.commit() + + unit_progress = StudentUnitProgress( + student_course_id=student_course.id, + unit_id=unit.id, + status=ProgressStatus.IN_PROGRESS, + ) + db_session.add(unit_progress) + db_session.commit() + db_session.refresh(unit_progress) + + resolved = crud.resolve_governed_segment_for_unit_progress(db_session, unit_progress.id) + + assert resolved is not None + assert resolved["delivery_state"] == "governed_available" + assert resolved["lesson_id"] == lesson.id + assert resolved["status"] == ProgressStatus.IN_PROGRESS + assert resolved["unit_progress_id"] == unit_progress.id + + segments = ( + db_session.query(SegmentProgress) + .filter_by(student_unit_id=unit_progress.id) + .all() + ) + assert len(segments) == 1 + assert segments[0].lesson_id == lesson.id + assert segments[0].status == ProgressStatus.IN_PROGRESS diff --git a/backend/tests/test_v2_platform_wrappers.py b/backend/tests/test_v2_platform_wrappers.py new file mode 100644 index 0000000..b444532 --- /dev/null +++ b/backend/tests/test_v2_platform_wrappers.py @@ -0,0 +1,1232 @@ +import uuid + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.routes import analytics, courses, v2_platform +from app.database import get_db +from app.deps import get_current_user +from app.enum import ProgressStatus +from app.enum import OrganizationType +from app.models import ( + AccessGrant, + Artifact, + Course, + GenerationRun, + KnowledgeSource, + Lesson, + Organization, + OrganizationMembership, + Product, + Project, + SegmentProgress, + Source, + StudentCourse, + StudentUnitProgress, + Unit, + User, + Workspace, +) + + +def _admin_user() -> User: + return User( + id=uuid.uuid4(), + firstname="Admin", + lastname="User", + username=f"admin_{uuid.uuid4().hex[:8]}", + email=f"admin_{uuid.uuid4().hex[:8]}@example.com", + hashed_password="hashed", + role="admin", + ) + + +def test_v2_wrapper_models_preserve_existing_course_lesson_relationships(db_session): + organization = Organization( + id=uuid.uuid4(), + name="Acme Learning", + type=OrganizationType.SCHOOL, + ) + course = Course( + id=uuid.uuid4(), + title="Existing Course", + description="Existing runtime course", + organization=organization, + ) + unit = Unit( + id=uuid.uuid4(), + course=course, + title="Existing Unit", + content="Runtime content", + order=1, + ) + lesson = Lesson( + id=uuid.uuid4(), + unit=unit, + title="Existing Lesson", + objective="Keep runtime behavior intact", + ) + source = Source( + id=uuid.uuid4(), + lesson=lesson, + citation="Existing source", + url="https://example.com/source", + ) + workspace = Workspace( + id=uuid.uuid4(), + organization=organization, + name="Acme Workspace", + ) + project = Project( + id=uuid.uuid4(), + workspace=workspace, + name="Onboarding Project", + ) + product = Product( + id=uuid.uuid4(), + workspace=workspace, + project=project, + course=course, + product_type="course", + title=course.title, + description=course.description, + status="active", + ) + knowledge_source = KnowledgeSource( + id=uuid.uuid4(), + workspace=workspace, + project=project, + source=source, + title="Existing source as knowledge", + source_type="lesson_source", + citation=source.citation, + ) + generation_run = GenerationRun( + id=uuid.uuid4(), + workspace=workspace, + project=project, + product=product, + status="completed", + provider="test-provider", + model_name="test-model", + prompt="Generate onboarding material", + output_summary="Generated an outline", + ) + artifact = Artifact( + id=uuid.uuid4(), + workspace=workspace, + project=project, + product=product, + generation_run=generation_run, + knowledge_source=knowledge_source, + artifact_type="guide", + title="Implementation Guide", + body="Reviewable generated content", + ) + + db_session.add_all([ + organization, + course, + unit, + lesson, + source, + workspace, + project, + product, + knowledge_source, + generation_run, + artifact, + ]) + db_session.commit() + db_session.expire_all() + + saved_course = db_session.query(Course).filter_by(id=course.id).one() + assert saved_course.units[0].lessons[0].title == "Existing Lesson" + assert saved_course.products[0].title == "Existing Course" + + saved_project = db_session.query(Project).filter_by(id=project.id).one() + assert saved_project.workspace.name == "Acme Workspace" + assert saved_project.products[0].course_id == course.id + assert saved_project.knowledge_sources[0].source_id == source.id + assert saved_project.generation_runs[0].artifacts[0].title == "Implementation Guide" + + +def test_v2_read_apis_list_wrappers_without_replacing_course_api(db_session): + admin = _admin_user() + organization = Organization( + id=uuid.uuid4(), + name="Runtime Org", + type=OrganizationType.SCHOOL, + ) + course = Course( + id=uuid.uuid4(), + title="Runtime Course", + description="Still served by /api/courses", + organization=organization, + ) + unit = Unit( + id=uuid.uuid4(), + course=course, + title="Runtime Unit", + content="Existing unit", + order=1, + ) + lesson = Lesson( + id=uuid.uuid4(), + unit=unit, + title="Runtime Lesson", + objective="Existing lesson", + ) + workspace = Workspace( + id=uuid.uuid4(), + organization=organization, + name="Runtime Workspace", + ) + project = Project( + id=uuid.uuid4(), + workspace=workspace, + name="Runtime Project", + ) + product = Product( + id=uuid.uuid4(), + workspace=workspace, + project=project, + course=course, + product_type="course", + title=course.title, + description=course.description, + status="active", + ) + + db_session.add_all([admin, organization, course, unit, lesson, workspace, project, product]) + db_session.commit() + + test_app = FastAPI() + test_app.include_router(v2_platform.router, prefix="/api") + test_app.include_router(courses.router, prefix="/api") + + def override_get_db(): + yield db_session + + test_app.dependency_overrides[get_db] = override_get_db + test_app.dependency_overrides[get_current_user] = lambda: admin + + client = TestClient(test_app) + + products_response = client.get("/api/products") + assert products_response.status_code == 200 + products_payload = products_response.json() + assert len(products_payload) == 1 + assert products_payload[0]["course_id"] == str(course.id) + assert products_payload[0]["title"] == "Runtime Course" + + courses_response = client.get(f"/api/courses/{course.id}") + assert courses_response.status_code == 200 + course_payload = courses_response.json() + assert course_payload["id"] == str(course.id) + assert course_payload["units"][0]["lessons"][0]["title"] == "Runtime Lesson" + assert "products" not in course_payload + + +def test_v2_create_project_and_product_shells_are_additive(db_session): + admin = _admin_user() + organization = Organization( + id=uuid.uuid4(), + name="Studio Org", + type=OrganizationType.SCHOOL, + ) + course = Course( + id=uuid.uuid4(), + title="Course Runtime", + description="Existing runtime remains untouched", + organization=organization, + ) + unit = Unit( + id=uuid.uuid4(), + course=course, + title="Runtime Unit", + content="Existing unit", + order=1, + ) + lesson = Lesson( + id=uuid.uuid4(), + unit=unit, + title="Runtime Lesson", + objective="Existing lesson", + ) + workspace = Workspace( + id=uuid.uuid4(), + organization=organization, + name="Studio Workspace", + ) + db_session.add_all([admin, organization, course, unit, lesson, workspace]) + db_session.commit() + + test_app = FastAPI() + test_app.include_router(v2_platform.router, prefix="/api") + + def override_get_db(): + yield db_session + + test_app.dependency_overrides[get_db] = override_get_db + test_app.dependency_overrides[get_current_user] = lambda: admin + + client = TestClient(test_app) + + project_response = client.post( + "/api/projects", + json={ + "workspace_id": str(workspace.id), + "name": "Studio Project", + "description": "Project shell only", + }, + ) + assert project_response.status_code == 201 + project_id = project_response.json()["id"] + + product_response = client.post( + "/api/products", + json={ + "workspace_id": str(workspace.id), + "project_id": project_id, + "course_id": str(course.id), + "product_type": "course", + "title": "Course Runtime Product", + "description": "Wrapper only", + }, + ) + assert product_response.status_code == 201 + payload = product_response.json() + assert payload["course_id"] == str(course.id) + assert payload["project_id"] == project_id + + db_session.expire_all() + assert db_session.query(Course).filter(Course.id == course.id).count() == 1 + assert db_session.query(Unit).filter(Unit.id == unit.id).count() == 1 + assert db_session.query(Lesson).filter(Lesson.id == lesson.id).count() == 1 + assert db_session.query(Project).filter(Project.id == uuid.UUID(project_id)).count() == 1 + assert db_session.query(Product).filter(Product.course_id == course.id).count() == 1 + + +def test_v2_create_product_reuses_existing_course_backed_wrapper(db_session): + admin = _admin_user() + organization = Organization( + id=uuid.uuid4(), + name="Reuse Org", + type=OrganizationType.SCHOOL, + ) + course = Course( + id=uuid.uuid4(), + title="Reusable Course", + description="Existing course", + organization=organization, + ) + workspace = Workspace( + id=uuid.uuid4(), + organization=organization, + name="Reuse Workspace", + ) + existing_product = Product( + id=uuid.uuid4(), + workspace=workspace, + course=course, + product_type="course", + title=course.title, + description=course.description, + status="active", + ) + db_session.add_all([admin, organization, course, workspace, existing_product]) + db_session.commit() + + test_app = FastAPI() + test_app.include_router(v2_platform.router, prefix="/api") + + def override_get_db(): + yield db_session + + test_app.dependency_overrides[get_db] = override_get_db + test_app.dependency_overrides[get_current_user] = lambda: admin + + client = TestClient(test_app) + response = client.post( + "/api/products", + json={ + "workspace_id": str(workspace.id), + "course_id": str(course.id), + "product_type": "course", + "title": "Duplicate wrapper request", + }, + ) + + assert response.status_code == 201 + assert response.json()["id"] == str(existing_product.id) + assert db_session.query(Product).count() == 1 + + +def test_v2_creates_knowledge_source_and_artifact_links_without_changing_runtime(db_session): + admin = _admin_user() + organization = Organization( + id=uuid.uuid4(), + name="Pipeline Org", + type=OrganizationType.SCHOOL, + ) + course = Course( + id=uuid.uuid4(), + title="Pipeline Course", + description="Existing runtime course", + organization=organization, + ) + unit = Unit( + id=uuid.uuid4(), + course=course, + title="Runtime Unit", + content="Existing unit", + order=1, + ) + lesson = Lesson( + id=uuid.uuid4(), + unit=unit, + title="Runtime Lesson", + objective="Runtime objective", + ) + source = Source( + id=uuid.uuid4(), + lesson=lesson, + citation="Runtime source", + url="https://example.com/runtime-source", + ) + workspace = Workspace( + id=uuid.uuid4(), + organization=organization, + name="Pipeline Workspace", + ) + project = Project( + id=uuid.uuid4(), + workspace=workspace, + name="Pipeline Project", + ) + product = Product( + id=uuid.uuid4(), + workspace=workspace, + project=project, + course=course, + product_type="course", + title="Pipeline Product", + ) + generation_run = GenerationRun( + id=uuid.uuid4(), + workspace=workspace, + project=project, + product=product, + status="completed", + provider="placeholder", + model_name="future-model", + prompt="Draft a guide", + output_summary="Placeholder output only", + ) + db_session.add_all([ + admin, + organization, + course, + unit, + lesson, + source, + workspace, + project, + product, + generation_run, + ]) + db_session.commit() + + test_app = FastAPI() + test_app.include_router(v2_platform.router, prefix="/api") + + def override_get_db(): + yield db_session + + test_app.dependency_overrides[get_db] = override_get_db + test_app.dependency_overrides[get_current_user] = lambda: admin + client = TestClient(test_app) + + source_response = client.post( + "/api/knowledge-sources", + json={ + "workspace_id": str(workspace.id), + "project_id": str(project.id), + "source_id": str(source.id), + "title": "Wrapped runtime source", + "source_type": "lesson_source", + "uri": source.url, + "citation": source.citation, + "metadata": {"wrapped_from": "lesson_source"}, + }, + ) + assert source_response.status_code == 201 + knowledge_source_id = source_response.json()["id"] + assert source_response.json()["source_id"] == str(source.id) + + artifact_response = client.post( + "/api/artifacts", + json={ + "workspace_id": str(workspace.id), + "project_id": str(project.id), + "product_id": str(product.id), + "generation_run_id": str(generation_run.id), + "knowledge_source_id": knowledge_source_id, + "artifact_type": "guide", + "title": "Implementation Guide", + "body": "Reviewable artifact content", + }, + ) + assert artifact_response.status_code == 201 + artifact_payload = artifact_response.json() + assert artifact_payload["status"] == "draft" + assert artifact_payload["review_state"] == "review_required" + assert artifact_payload["product_id"] == str(product.id) + assert artifact_payload["generation_run_id"] == str(generation_run.id) + assert artifact_payload["knowledge_source_id"] == knowledge_source_id + + assert client.get(f"/api/projects/{project.id}/knowledge-sources").json()[0]["id"] == knowledge_source_id + assert client.get(f"/api/projects/{project.id}/artifacts").json()[0]["id"] == artifact_payload["id"] + assert client.get(f"/api/projects/{project.id}/generation-runs").json()[0]["id"] == str(generation_run.id) + assert client.get(f"/api/projects/{project.id}/products").json()[0]["id"] == str(product.id) + + db_session.expire_all() + assert db_session.query(Source).filter(Source.id == source.id).one().citation == "Runtime source" + assert db_session.query(Course).filter(Course.id == course.id).count() == 1 + assert db_session.query(Lesson).filter(Lesson.id == lesson.id).count() == 1 + + +def test_v2_rejects_unsupported_source_and_artifact_types(db_session): + admin = _admin_user() + organization = Organization( + id=uuid.uuid4(), + name="Validation Org", + type=OrganizationType.SCHOOL, + ) + workspace = Workspace( + id=uuid.uuid4(), + organization=organization, + name="Validation Workspace", + ) + project = Project( + id=uuid.uuid4(), + workspace=workspace, + name="Validation Project", + ) + db_session.add_all([admin, organization, workspace, project]) + db_session.commit() + + test_app = FastAPI() + test_app.include_router(v2_platform.router, prefix="/api") + + def override_get_db(): + yield db_session + + test_app.dependency_overrides[get_db] = override_get_db + test_app.dependency_overrides[get_current_user] = lambda: admin + client = TestClient(test_app) + + source_response = client.post( + "/api/knowledge-sources", + json={ + "workspace_id": str(workspace.id), + "project_id": str(project.id), + "title": "Unsupported source", + "source_type": "unknown", + }, + ) + assert source_response.status_code == 400 + + artifact_response = client.post( + "/api/artifacts", + json={ + "workspace_id": str(workspace.id), + "project_id": str(project.id), + "artifact_type": "unknown", + "title": "Unsupported artifact", + }, + ) + assert artifact_response.status_code == 400 + + +def test_review_center_lists_v2_wrappers_and_existing_governance_items(db_session): + admin = _admin_user() + organization = Organization( + id=uuid.uuid4(), + name="Review Org", + type=OrganizationType.SCHOOL, + ) + course = Course( + id=uuid.uuid4(), + title="Blocked Runtime Course", + description="Existing course remains governed", + organization=organization, + ) + unit = Unit(id=uuid.uuid4(), course=course, title="Runtime Unit", content="Runtime", order=1) + lesson = Lesson( + id=uuid.uuid4(), + unit=unit, + title="Draft Runtime Lesson", + objective="Needs source and approval", + order=1, + review_status="draft", + ) + workspace = Workspace(id=uuid.uuid4(), organization=organization, name="Review Workspace") + project = Project(id=uuid.uuid4(), workspace=workspace, name="Review Project") + product = Product( + id=uuid.uuid4(), + workspace=workspace, + project=project, + course=course, + product_type="course", + title="Draft Product", + status="draft", + ) + artifact = Artifact( + id=uuid.uuid4(), + workspace=workspace, + project=project, + product=product, + artifact_type="lesson_draft", + title="Pending Lesson Draft", + body="Reviewable wrapper content", + status="in_review", + ) + db_session.add_all([admin, organization, course, unit, lesson, workspace, project, product, artifact]) + db_session.commit() + + test_app = FastAPI() + test_app.include_router(v2_platform.router, prefix="/api") + + def override_get_db(): + yield db_session + + test_app.dependency_overrides[get_db] = override_get_db + test_app.dependency_overrides[get_current_user] = lambda: admin + client = TestClient(test_app) + + response = client.get("/api/review-center") + + assert response.status_code == 200 + payload = response.json() + assert payload["pending_artifacts"][0]["id"] == str(artifact.id) + assert payload["pending_artifacts"][0]["status"] == "in_review" + assert payload["draft_products"][0]["id"] == str(product.id) + assert payload["draft_products"][0]["status"] == "draft" + assert payload["lesson_governance_items"][0]["id"] == str(course.id) + assert payload["lesson_governance_items"][0]["status"] == "blocked" + assert payload["recent_activity"][0]["id"] == "review-activity-placeholder" + + +def test_review_status_updates_only_change_v2_wrapper_records(db_session): + admin = _admin_user() + organization = Organization( + id=uuid.uuid4(), + name="Status Org", + type=OrganizationType.SCHOOL, + ) + course = Course( + id=uuid.uuid4(), + title="Runtime Status Course", + description="Runtime course", + organization=organization, + published_at=None, + ) + unit = Unit(id=uuid.uuid4(), course=course, title="Runtime Unit", content="Runtime", order=1) + lesson = Lesson(id=uuid.uuid4(), unit=unit, title="Runtime Lesson", objective="Draft", review_status="draft") + workspace = Workspace(id=uuid.uuid4(), organization=organization, name="Status Workspace") + project = Project(id=uuid.uuid4(), workspace=workspace, name="Status Project") + product = Product( + id=uuid.uuid4(), + workspace=workspace, + project=project, + course=course, + product_type="course", + title="Status Product", + status="draft", + review_state="not_reviewed", + ) + artifact = Artifact( + id=uuid.uuid4(), + workspace=workspace, + project=project, + product=product, + artifact_type="guide", + title="Status Artifact", + status="draft", + review_state="review_required", + ) + db_session.add_all([admin, organization, course, unit, lesson, workspace, project, product, artifact]) + db_session.commit() + + test_app = FastAPI() + test_app.include_router(v2_platform.router, prefix="/api") + + def override_get_db(): + yield db_session + + test_app.dependency_overrides[get_db] = override_get_db + test_app.dependency_overrides[get_current_user] = lambda: admin + client = TestClient(test_app) + + artifact_response = client.patch( + f"/api/artifacts/{artifact.id}/review-status", + json={"status": "approved"}, + ) + product_response = client.patch( + f"/api/products/{product.id}/review-status", + json={"status": "approved"}, + ) + invalid_response = client.patch( + f"/api/artifacts/{artifact.id}/review-status", + json={"status": "published"}, + ) + + assert artifact_response.status_code == 200 + assert artifact_response.json()["status"] == "approved" + assert artifact_response.json()["review_state"] == "approved" + assert product_response.status_code == 200 + assert product_response.json()["status"] == "approved" + assert product_response.json()["review_state"] == "approved" + assert invalid_response.status_code == 400 + + db_session.expire_all() + saved_course = db_session.query(Course).filter(Course.id == course.id).one() + saved_lesson = db_session.query(Lesson).filter(Lesson.id == lesson.id).one() + assert saved_course.published_at is None + assert saved_lesson.review_status == "draft" + + +def test_approved_artifact_does_not_make_draft_lesson_learner_visible(db_session): + admin = _admin_user() + learner = User( + id=uuid.uuid4(), + firstname="Learner", + lastname="User", + username=f"learner_{uuid.uuid4().hex[:8]}", + email=f"learner_{uuid.uuid4().hex[:8]}@example.com", + hashed_password="hashed", + role="student", + ) + organization = Organization( + id=uuid.uuid4(), + name="Learner Safety Org", + type=OrganizationType.SCHOOL, + ) + course = Course( + id=uuid.uuid4(), + title="Learner Safety Course", + description="Existing governed course", + organization=organization, + ) + unit = Unit(id=uuid.uuid4(), course=course, title="Runtime Unit", content="Runtime", order=1) + lesson = Lesson( + id=uuid.uuid4(), + unit=unit, + title="Unapproved Runtime Lesson", + objective="Still draft", + review_status="draft", + order=1, + ) + workspace = Workspace(id=uuid.uuid4(), organization=organization, name="Learner Safety Workspace") + project = Project(id=uuid.uuid4(), workspace=workspace, name="Learner Safety Project") + product = Product( + id=uuid.uuid4(), + workspace=workspace, + project=project, + course=course, + product_type="course", + title="Learner Safety Product", + ) + artifact = Artifact( + id=uuid.uuid4(), + workspace=workspace, + project=project, + product=product, + artifact_type="lesson_draft", + title="Approved Wrapper Only", + status="approved", + review_state="approved", + ) + db_session.add_all([admin, learner, organization, course, unit, lesson, workspace, project, product, artifact]) + db_session.commit() + + test_app = FastAPI() + test_app.include_router(courses.router, prefix="/api") + + def override_get_db(): + yield db_session + + test_app.dependency_overrides[get_db] = override_get_db + test_app.dependency_overrides[get_current_user] = lambda: learner + client = TestClient(test_app) + + response = client.get(f"/api/courses/{course.id}") + + assert response.status_code == 200 + payload = response.json() + assert payload["units"][0]["learner_availability"] == "no_approved_content" + assert payload["units"][0]["lessons"] == [] + + +def test_learner_portal_products_use_enrollments_and_active_grants(db_session): + learner = User( + id=uuid.uuid4(), + firstname="Learner", + lastname="Portal", + username=f"learner_{uuid.uuid4().hex[:8]}", + email=f"learner_{uuid.uuid4().hex[:8]}@example.com", + hashed_password="hashed", + role="student", + ) + organization = Organization( + id=uuid.uuid4(), + name="Learner Product Org", + type=OrganizationType.SCHOOL, + ) + membership = OrganizationMembership( + id=uuid.uuid4(), + organization=organization, + user=learner, + role="student", + status="active", + ) + workspace = Workspace(id=uuid.uuid4(), organization=organization, name="Learner Product Workspace") + project = Project(id=uuid.uuid4(), workspace=workspace, name="Learner Product Project") + enrolled_course = Course( + id=uuid.uuid4(), + title="Enrolled Course", + description="Existing runtime enrollment", + organization=organization, + ) + available_course = Course( + id=uuid.uuid4(), + title="Available Course", + description="Approved wrapper", + organization=organization, + ) + draft_course = Course( + id=uuid.uuid4(), + title="Draft Wrapper Course", + description="Should not be available through learner products", + organization=organization, + ) + enrollment = StudentCourse( + id=uuid.uuid4(), + student=learner, + course=enrolled_course, + status="active", + ) + enrolled_product = Product( + id=uuid.uuid4(), + workspace=workspace, + project=project, + course=enrolled_course, + product_type="course", + title="Draft Enrolled Product", + status="draft", + review_state="draft", + ) + available_product = Product( + id=uuid.uuid4(), + workspace=workspace, + project=project, + course=available_course, + product_type="course", + title="Approved Available Product", + status="approved", + review_state="approved", + ) + available_grant = AccessGrant( + id=uuid.uuid4(), + user=learner, + product=available_product, + workspace=workspace, + project=project, + grant_type="manual", + status="active", + source="manual", + ) + draft_product = Product( + id=uuid.uuid4(), + workspace=workspace, + project=project, + course=draft_course, + product_type="course", + title="Hidden Draft Product", + status="draft", + review_state="draft", + ) + db_session.add_all([ + learner, + organization, + membership, + workspace, + project, + enrolled_course, + available_course, + draft_course, + enrollment, + enrolled_product, + available_product, + draft_product, + available_grant, + ]) + db_session.commit() + + test_app = FastAPI() + test_app.include_router(v2_platform.router, prefix="/api") + + def override_get_db(): + yield db_session + + test_app.dependency_overrides[get_db] = override_get_db + test_app.dependency_overrides[get_current_user] = lambda: learner + client = TestClient(test_app) + + response = client.get("/api/learner-portal/products") + + assert response.status_code == 200 + payload = response.json() + titles = {item["title"] for item in payload} + assert "Draft Enrolled Product" in titles + assert "Approved Available Product" in titles + assert "Hidden Draft Product" not in titles + + enrolled = next(item for item in payload if item["title"] == "Draft Enrolled Product") + available = next(item for item in payload if item["title"] == "Approved Available Product") + assert enrolled["is_enrolled"] is True + assert enrolled["product_status"] == "draft" + assert enrolled["learner_visibility"].startswith("Existing enrollment controls access") + assert available["is_enrolled"] is False + assert available["product_status"] == "approved" + assert available["access_grant_id"] == str(available_grant.id) + + +def test_access_grant_create_revoke_and_learner_visibility_are_additive(db_session): + admin = _admin_user() + learner = User( + id=uuid.uuid4(), + firstname="Grant", + lastname="Learner", + username=f"grant_learner_{uuid.uuid4().hex[:8]}", + email=f"grant_learner_{uuid.uuid4().hex[:8]}@example.com", + hashed_password="hashed", + role="student", + ) + organization = Organization( + id=uuid.uuid4(), + name="Grant Org", + type=OrganizationType.SCHOOL, + ) + workspace = Workspace(id=uuid.uuid4(), organization=organization, name="Grant Workspace") + project = Project(id=uuid.uuid4(), workspace=workspace, name="Grant Project") + course = Course( + id=uuid.uuid4(), + title="Grant Course", + description="Access grant backed course", + organization=organization, + ) + unit = Unit(id=uuid.uuid4(), course=course, title="Grant Unit", content="Runtime", order=1) + lesson = Lesson( + id=uuid.uuid4(), + unit=unit, + title="Grant Draft Lesson", + objective="Still governed", + review_status="draft", + order=1, + ) + product = Product( + id=uuid.uuid4(), + workspace=workspace, + project=project, + course=course, + product_type="course", + title="Grant Product", + status="approved", + review_state="approved", + ) + db_session.add_all([admin, learner, organization, workspace, project, course, unit, lesson, product]) + db_session.commit() + + test_app = FastAPI() + test_app.include_router(v2_platform.router, prefix="/api") + test_app.include_router(courses.router, prefix="/api") + + def override_get_db(): + yield db_session + + test_app.dependency_overrides[get_db] = override_get_db + test_app.dependency_overrides[get_current_user] = lambda: admin + admin_client = TestClient(test_app) + + create_response = admin_client.post( + "/api/access-grants", + json={ + "user_id": str(learner.id), + "product_id": str(product.id), + "grant_type": "manual", + "source": "manual", + "metadata": {"reason": "phase7-test"}, + }, + ) + assert create_response.status_code == 201 + grant_payload = create_response.json() + grant_id = grant_payload["id"] + assert grant_payload["status"] == "active" + assert grant_payload["workspace_id"] == str(workspace.id) + assert db_session.query(StudentCourse).filter(StudentCourse.student_id == learner.id).count() == 0 + + list_response = admin_client.get("/api/access-grants") + assert list_response.status_code == 200 + assert list_response.json()[0]["id"] == grant_id + + test_app.dependency_overrides[get_current_user] = lambda: learner + learner_client = TestClient(test_app) + learner_products_response = learner_client.get("/api/learner-portal/products") + assert learner_products_response.status_code == 200 + learner_products = learner_products_response.json() + assert learner_products[0]["title"] == "Grant Product" + assert learner_products[0]["access_grant_id"] == grant_id + assert learner_products[0]["is_enrolled"] is False + assert learner_products[0]["learner_visibility"].startswith("Active access grant") + + course_response = learner_client.get(f"/api/courses/{course.id}") + assert course_response.status_code == 200 + course_payload = course_response.json() + assert course_payload["units"][0]["learner_availability"] == "no_approved_content" + assert course_payload["units"][0]["lessons"] == [] + + test_app.dependency_overrides[get_current_user] = lambda: admin + revoke_response = admin_client.patch(f"/api/access-grants/{grant_id}/revoke") + assert revoke_response.status_code == 200 + assert revoke_response.json()["status"] == "revoked" + + test_app.dependency_overrides[get_current_user] = lambda: learner + after_revoke_response = learner_client.get("/api/learner-portal/products") + assert after_revoke_response.status_code == 200 + assert after_revoke_response.json() == [] + + +def test_access_grant_does_not_expose_unapproved_product_to_learner_portal(db_session): + admin = _admin_user() + learner = User( + id=uuid.uuid4(), + firstname="Draft", + lastname="Learner", + username=f"draft_learner_{uuid.uuid4().hex[:8]}", + email=f"draft_learner_{uuid.uuid4().hex[:8]}@example.com", + hashed_password="hashed", + role="student", + ) + organization = Organization( + id=uuid.uuid4(), + name="Draft Grant Org", + type=OrganizationType.SCHOOL, + ) + workspace = Workspace(id=uuid.uuid4(), organization=organization, name="Draft Grant Workspace") + product = Product( + id=uuid.uuid4(), + workspace=workspace, + product_type="resource_pack", + title="Draft Granted Product", + status="draft", + review_state="draft", + ) + grant = AccessGrant( + id=uuid.uuid4(), + user=learner, + product=product, + workspace=workspace, + grant_type="manual", + status="active", + source="manual", + ) + db_session.add_all([admin, learner, organization, workspace, product, grant]) + db_session.commit() + + test_app = FastAPI() + test_app.include_router(v2_platform.router, prefix="/api") + + def override_get_db(): + yield db_session + + test_app.dependency_overrides[get_db] = override_get_db + test_app.dependency_overrides[get_current_user] = lambda: learner + client = TestClient(test_app) + + response = client.get("/api/learner-portal/products") + + assert response.status_code == 200 + assert response.json() == [] + + +def test_analytics_v2_reports_wrapper_and_runtime_state_read_only(db_session): + admin = _admin_user() + learner = User( + id=uuid.uuid4(), + firstname="Metric", + lastname="Learner", + username=f"metric_learner_{uuid.uuid4().hex[:8]}", + email=f"metric_learner_{uuid.uuid4().hex[:8]}@example.com", + hashed_password="hashed", + role="student", + ) + organization = Organization( + id=uuid.uuid4(), + name="Analytics Org", + type=OrganizationType.SCHOOL, + ) + course = Course( + id=uuid.uuid4(), + title="Analytics Course", + description="Runtime course for analytics", + organization=organization, + ) + unit = Unit( + id=uuid.uuid4(), + course=course, + title="Analytics Unit", + content="Runtime unit", + order=1, + ) + lesson = Lesson( + id=uuid.uuid4(), + unit=unit, + title="Analytics Lesson", + objective="Track existing progress", + ) + enrollment = StudentCourse( + id=uuid.uuid4(), + student=learner, + course=course, + status="active", + ) + unit_progress = StudentUnitProgress( + id=uuid.uuid4(), + student_course=enrollment, + unit=unit, + status=ProgressStatus.COMPLETED, + ) + segment_progress = SegmentProgress( + id=uuid.uuid4(), + student_unit=unit_progress, + lesson=lesson, + status=ProgressStatus.COMPLETED, + ) + workspace = Workspace(id=uuid.uuid4(), organization=organization, name="Analytics Workspace") + project = Project(id=uuid.uuid4(), workspace=workspace, name="Analytics Project") + product = Product( + id=uuid.uuid4(), + workspace=workspace, + project=project, + course=course, + product_type="course", + title="Analytics Product", + status="published", + review_state="approved", + ) + knowledge_source = KnowledgeSource( + id=uuid.uuid4(), + workspace=workspace, + project=project, + title="Analytics Source", + source_type="lesson_source", + status="available", + ) + generation_run = GenerationRun( + id=uuid.uuid4(), + workspace=workspace, + project=project, + product=product, + status="completed", + ) + artifact = Artifact( + id=uuid.uuid4(), + workspace=workspace, + project=project, + product=product, + knowledge_source=knowledge_source, + generation_run=generation_run, + artifact_type="guide", + title="Analytics Artifact", + status="needs_changes", + review_state="needs_changes", + ) + access_grant = AccessGrant( + id=uuid.uuid4(), + user=learner, + product=product, + workspace=workspace, + grant_type="manual", + status="active", + source="manual", + ) + db_session.add_all([ + admin, + learner, + organization, + course, + unit, + lesson, + enrollment, + unit_progress, + segment_progress, + workspace, + project, + product, + knowledge_source, + generation_run, + artifact, + access_grant, + ]) + db_session.commit() + + initial_enrollment_count = db_session.query(StudentCourse).count() + test_app = FastAPI() + test_app.include_router(analytics.router, prefix="/api") + + def override_get_db(): + yield db_session + + test_app.dependency_overrides[get_db] = override_get_db + test_app.dependency_overrides[get_current_user] = lambda: admin + client = TestClient(test_app) + + workspace_response = client.get("/api/analytics/workspace") + products_response = client.get("/api/analytics/products") + learners_response = client.get("/api/analytics/learners") + pipeline_response = client.get("/api/analytics/knowledge-pipeline") + + assert workspace_response.status_code == 200 + workspace_payload = workspace_response.json() + assert workspace_payload["totals"]["total_products"] == 1 + assert workspace_payload["totals"]["total_projects"] == 1 + assert workspace_payload["totals"]["total_knowledge_sources"] == 1 + assert workspace_payload["totals"]["total_artifacts"] == 1 + assert workspace_payload["totals"]["total_access_grants"] == 1 + assert workspace_payload["products_by_type"]["course"] == 1 + assert workspace_payload["products_by_status"]["published"] == 1 + assert workspace_payload["artifacts_by_status"]["needs_changes"] == 1 + assert workspace_payload["access_grants_by_type"]["manual"] == 1 + assert workspace_payload["learner_engagement"]["lesson_completion_count"] == 1 + assert workspace_payload["event_tracking"]["status"] == "unavailable" + + assert products_response.status_code == 200 + products_payload = products_response.json() + assert products_payload["course_backed_product_count"] == 1 + assert products_payload["products"][0]["enrollment_count"] == 1 + assert products_payload["products"][0]["artifact_count"] == 1 + + assert learners_response.status_code == 200 + learners_payload = learners_response.json() + assert learners_payload["summary"]["learner_count"] == 1 + assert learners_payload["enrollments_by_status"]["active"] == 1 + + assert pipeline_response.status_code == 200 + pipeline_payload = pipeline_response.json() + assert pipeline_payload["total_generation_runs"] == 1 + assert pipeline_payload["source_coverage"]["artifacts_with_knowledge_source"] == 1 + + assert db_session.query(StudentCourse).count() == initial_enrollment_count diff --git a/docs/demo-readiness.md b/docs/demo-readiness.md index c13ee3f..2682f7c 100644 --- a/docs/demo-readiness.md +++ b/docs/demo-readiness.md @@ -1,9 +1,11 @@ # Demo Readiness Runbook -This runbook documents the deterministic EchoEd demo environment used for the flagship `Introduction to Africa` walkthrough. +This runbook documents the bounded EchoEd pilot-demo startup flow for the deterministic flagship environment. -Related planning document: +Related runbooks: +- [Demo Smoke Validation](./demo-smoke-validation.md) +- [Pilot Verification Checklist](./pilot-verification-checklist.md) - [Demo UI Polish Checklist](./demo-ui-polish-checklist.md) ## Purpose @@ -11,11 +13,39 @@ Related planning document: Use this flow before a live demo when you need to: - apply schema migrations -- rebuild the canonical demo organization and users -- reset flagship learner progress and evidence into known states -- verify the expected teacher and admin story without manual repair +- reseed the canonical demo organization, users, and flagship course +- restore deterministic learner/runtime states +- verify the bounded student and teacher walkthrough paths without manual database repair -## Demo Setup +## Canonical Demo Context + +- Demo organization: `EchoEd Demo School` +- Flagship course: `Introduction to Africa` +- Demo section: `Grade 3 - Cohort A` +- Shared seeded password: `password` + +## Canonical Demo Accounts + +### Staff + +- `orgadmin` / `password` +- `teacher` / `password` +- `contentadmin` / `password` + +### Reference students + +- `student1` / `password` +- `student2` / `password` + +### Deterministic flagship archetypes + +- `masteredstudent` -> expected runtime state `enrichment` +- `reteachstudent` -> expected runtime state `reteach` +- `reviewstudent` -> expected runtime state `review` +- `monitorstudent` -> expected runtime state `monitor` +- `normalstudent` -> expected runtime state `normal` + +## Startup Flow ### 1. Apply backend migrations @@ -26,8 +56,6 @@ cd backend venv\Scripts\python.exe -m alembic upgrade head ``` -If you are using an activated virtual environment, `python -m alembic upgrade head` is equivalent. - ### 2. Reseed the deterministic demo From the repo root: @@ -36,247 +64,110 @@ From the repo root: backend\venv\Scripts\python.exe backend\scripts\reseed_demo.py ``` -What this does: +What this guarantees: -- reuses the real `app.seed_demo.run()` entrypoint -- refreshes the canonical demo organization and memberships -- refreshes the flagship `Introduction to Africa` course structure -- reseeds the canonical section, enrollments, student-course rows, progress rows, attempts, answers, and attempt events -- restores the demo archetypes to stable runtime states +- canonical demo users are reset to their known usernames, emails, roles, and passwords +- duplicate demo identities are cleaned up before final upsert +- flagship learner progress, attempts, and intervention states return to deterministic demo defaults -The command is safe to rerun for repeat demo preparation. It is intentionally bounded to the demo-scoped seed identities and flagship course state. +### 3. Start the backend -## Canonical Demo Context +Make the backend available locally at `http://127.0.0.1:8000` using your normal development startup command. -- Demo organization: `EchoEd Demo School` -- Flagship course: `Introduction to Africa` -- Demo section: `Grade 3 - Cohort A` -- Shared password for seeded accounts: `password` +### 4. Start the frontend -## Demo Accounts +From `frontend`: -### Staff accounts +```powershell +npm start +``` -- Org admin: `orgadmin` / `password` -- Teacher: `teacher` / `password` -- Content admin: `contentadmin` / `password` +The frontend dev server is expected at `http://127.0.0.1:4200` and proxies `/api` calls to the backend. -### Reference student accounts +## Automated Confidence Layer -- `student1` / `password` -- `student2` / `password` +Phase 1 treats these as the authoritative automated checks: -These two learners stay available as clean student accounts for baseline learner-flow demos and regression coverage. +```powershell +cd backend +pytest -q +``` + +```powershell +cd frontend +cmd /c .\node_modules\.bin\ng.cmd test --watch=false --browsers=ChromeHeadless +``` -## Flagship Learner Archetypes +```powershell +cd frontend +cmd /c .\node_modules\.bin\playwright.cmd test tests/demo/student-flagship-smoke.spec.ts +``` -The seeded archetypes are the canonical learners for the deterministic teacher/admin demo story. +What they prove: -- Enrichment/mastered: `masteredstudent` / `password` - - Expected runtime recommendation: `enrichment` - - Expected state: completed flagship course with completed unit and segment progress -- Reteach/struggling: `reteachstudent` / `password` - - Expected runtime recommendation: `reteach` - - Expected state: active flagship course with weak real assessment evidence -- Review/moderate: `reviewstudent` / `password` - - Expected runtime recommendation: `review` - - Expected state: active flagship course with mixed but coherent evidence -- Monitor/ambiguous: `monitorstudent` / `password` - - Expected runtime recommendation: `monitor` - - Expected state: active flagship course with ambiguous competency evidence -- Normal progression: `normalstudent` / `password` - - Expected runtime recommendation: `normal` - - Expected state: active flagship course with governed progress and no special intervention signal +- backend runtime, progression, auth, and demo-seed regressions remain covered +- Angular component and route behavior remains covered +- one real-browser student flagship path still works: login, dashboard, continue lesson, governed lesson entry -## Expected Teacher/Admin Outcomes +What remains intentionally manual in Phase 1: -After reseeding, staff-facing reads should be predictable: - -- The flagship teacher runtime support view should surface a stable mix of intervention needs across the archetypes. -- The mastered learner should present as enrichment-ready. -- The reteach learner should present as needing targeted reteach support. -- The review learner should present as needing short review before continuing. -- The monitor learner should present as needing educator monitoring because the evidence is intentionally ambiguous. -- The normal learner should present as governed normal continuation. - -## Staff Governance Demo Checks - -Use these checks when you want to prove the flagship demo course is staff-ready from a governance perspective. - -### 1. Publish readiness - -- Inspect: `GET /api/courses/{course_id}/publish-readiness` or the staff publish-readiness section. -- Expected outcome: the flagship course is ready to publish. -- Expected details: - - `is_ready = true` - - no blocking issues - - no warnings -- Demo users: `orgadmin`, `teacher`, or `contentadmin` -- Expected vs unexpected: - - expected: no publish-readiness warnings - - unexpected: missing readiness fields, draft review status, or ordering issues - -### 2. Safe publish validation - -- Inspect: `GET /api/courses/{course_id}/safe-publish-validation` or the staff safe-publish section. -- Expected outcome: the flagship course is safe to publish. -- Expected details: - - `is_safe = true` - - no blocking issues - - no warnings -- Demo users: `orgadmin`, `teacher`, or `contentadmin` -- Expected vs unexpected: - - expected: no safe-publish warnings - - unexpected: blocked revision state, unsafe historical evidence, or deprecated learner-facing curriculum state - -### 3. Governance summary - -- Inspect: `GET /api/courses/{course_id}/governance-summary` -- Expected outcome: one staff-readable payload with all governance sections present. -- UI alignment: if you are showing the staff governance dashboard, this route is the backend summary payload that should back the existing governance sections without changing their wording. -- Expected sections: - - `publish_readiness` - - `safe_publish_validation` - - `lineage_safety_visibility` - - `competency_evidence_integrity` - - `runtime_intervention_recommendations` -- Demo users: `orgadmin` is the simplest demo account for this check -- Expected vs unexpected: - - expected: lineage/safety shows coherent and safe - - unexpected: missing sections, learner access, or route-level mismatches between summary and individual reads - -### 4. Competency evidence integrity - -- Inspect: `GET /api/courses/{course_id}/competency-evidence-integrity` -- Expected outcome: valid but intentionally not fully explainable. -- Expected details: - - `is_valid = true` - - `is_explainable = false` - - one warning on `Introduction to Africa Ambiguous Evidence Check` - - warning code: `unaligned_assessment_mastery_evidence` -- Demo user/archetype proving the warning: `monitorstudent` / Dayo Monitor -- Expected vs unexpected: - - expected: exactly the ambiguity warning tied to the ambiguous evidence check - - unexpected: blocking issues, unsafe historical evidence, or unexplained warnings on the flagship aligned checks - -### 5. Runtime intervention recommendations - -- Inspect: `GET /api/courses/{course_id}/runtime-intervention-recommendations` or the staff runtime/intervention section. -- Expected outcome: all five learner stories are present. -- Expected learner mapping: - - `masteredstudent` / Amara Mastery -> `enrichment` - - `reteachstudent` / Binta Reteach -> `reteach` - - `reviewstudent` / Chidi Review -> `review` - - `monitorstudent` / Dayo Monitor -> `monitor` - - `normalstudent` / Eshe Steady -> `normal` -- Expected vs unexpected: - - expected: `monitorstudent` includes `ambiguous_competency_evidence` - - expected: `monitorstudent` and `normalstudent` include `limited_mastery_evidence` - - unexpected: all learners collapsing to one state, or the ambiguous evidence warning leaking onto the aligned archetypes - -### 6. Learner denial - -- Inspect: attempt the same governance endpoints while authenticated as `student1` or `student2` -- Expected outcome: learners are denied from staff governance reads. -- Expected details: - - `403` for publish readiness - - `403` for safe publish validation - - `403` for competency evidence integrity - - `403` for governance summary - - `403` for runtime intervention recommendations - -## Recommended Live Demo Path +- teacher dashboard walkthrough quality and narration +- staff governance-summary storytelling +- broader platform resilience outside the bounded flagship smoke + +## Recommended Demo Walkthrough 1. Run migrations. 2. Run `backend\venv\Scripts\python.exe backend\scripts\reseed_demo.py`. -3. Sign in as `teacher` and open the flagship course. -4. Show the learner mix through the deterministic runtime/intervention story: - - `masteredstudent` - - `reteachstudent` - - `reviewstudent` - - `monitorstudent` - - `normalstudent` -5. Sign in as `orgadmin` if you need the staff/admin view of the same flagship environment. -6. Use `student1` or `student2` for a clean learner-flow walkthrough when you do not want preseeded attempt history. - -## Verification Checklist - -Use this as a quick operator check after reseeding: - -- The command exits successfully. -- The output references `EchoEd Demo School`. -- The output lists `orgadmin`, `teacher`, and the five archetype learners. -- The flagship course remains `Introduction to Africa`. -- The teacher dashboard can discover the archetype learners without manual enrollment repair. -- The governance summary route returns all expected sections for the flagship course. -- Publish readiness and safe publish validation are clean for the flagship course. -- Runtime intervention recommendations show enrichment, reteach, review, monitor, and normal at the same time. -- Competency evidence integrity shows the expected ambiguous warning for `monitorstudent` and no blocking issues. - -## Final Operator Validation Checklist - -Use this when you need a stricter go or no-go check immediately before a live demo. - -- Run migrations and rerun `backend\venv\Scripts\python.exe backend\scripts\reseed_demo.py` without manual database cleanup. -- Confirm the reseed output completes successfully and still names `EchoEd Demo School` and `Introduction to Africa`. -- Sign in as `normalstudent` and confirm the flagship course starts on a governed lesson with normal continuation guidance. -- Sign in as `teacher` and confirm the runtime intervention recommendation mix includes: - - `Amara Mastery` -> `enrichment` - - `Binta Reteach` -> `reteach` - - `Chidi Review` -> `review` - - `Dayo Monitor` -> `monitor` - - `Eshe Steady` -> `normal` -- Sign in as `teacher` and confirm the educator runtime support view remains stable: - - `Amara Mastery` shows `completed` support with `celebrate-and-reflect` - - `Binta Reteach` shows `remediation` support with `review-and-continue` - - `Chidi Review` shows `remediation` support with `review-and-continue` - - `Dayo Monitor` and `Eshe Steady` remain in normal continuation support -- Sign in as `orgadmin` and confirm `GET /api/courses/{course_id}/governance-summary` returns: - - `publish_readiness` - - `safe_publish_validation` - - `lineage_safety_visibility` - - `competency_evidence_integrity` - - `runtime_intervention_recommendations` -- Confirm publish readiness is clean, safe publish validation is clean, and lineage safety remains coherent and safe. -- Confirm learner access is still denied from the staff governance endpoints. -- Treat any need for manual row cleanup, manual reenrollment, or manual attempt deletion as a demo-readiness failure. - -## Known Acceptable Demo Warnings - -One governance warning is intentionally acceptable in the flagship demo: - -- Surface: `competency_evidence_integrity` -- Warning code: `unaligned_assessment_mastery_evidence` -- Assessment: `Introduction to Africa Ambiguous Evidence Check` -- Demonstrated by: `monitorstudent` / Dayo Monitor -- Why it is acceptable: - - the course remains valid and safe - - the warning is deterministic across reseeds - - it demonstrates realistic staff governance ambiguity rather than data corruption - - it should appear without any blocking issues - -Unexpected warnings for the flagship demo include: - -- additional competency evidence integrity warnings -- any blocking governance issue -- publish readiness warnings -- safe publish warnings -- lineage safety warnings - -## Recovery Steps If Reseed Validation Fails - -1. Rerun migrations from `backend` with `venv\Scripts\python.exe -m alembic upgrade head`. -2. Rerun `backend\venv\Scripts\python.exe backend\scripts\reseed_demo.py`. -3. Confirm the reseed summary still names `EchoEd Demo School`, `Introduction to Africa`, `orgadmin`, `teacher`, and the five archetype learners. -4. Recheck the flagship student flow with `normalstudent`. -5. Recheck the teacher runtime recommendation mix and the `orgadmin` governance summary. -6. If learner access is no longer denied, or if the acceptable ambiguity warning becomes blocking, stop the demo prep and treat the environment as invalid. -7. If stale auth or session data appears to be masking the expected state, sign out, start a new session, and repeat the validation checks before changing any data manually. +3. Sign in as `normalstudent` and confirm the active flagship course appears. +4. Continue into the governed lesson and confirm the lesson view loads. +5. Return safely to the dashboard. +6. Sign in as `teacher` and confirm the dashboard loads with stable flagship visibility. +7. Sign in as `orgadmin` only if you need the staff governance route checks. + +## Expected Teacher/Admin Outcomes + +After reseeding: + +- the teacher dashboard should open without manual enrollment repair +- the flagship course should remain visible and stable +- the runtime intervention mix should remain deterministic across the archetype learners +- learner access should still be denied from staff governance endpoints + +## Known Acceptable Demo Warning + +One warning remains intentionally acceptable in the flagship demo: + +- surface: `competency_evidence_integrity` +- warning code: `unaligned_assessment_mastery_evidence` +- assessment: `Introduction to Africa Ambiguous Evidence Check` +- demonstrated by: `monitorstudent` + +This warning is acceptable because it is deterministic, non-blocking, and intentionally demonstrates bounded educator ambiguity rather than corrupted data. + +## Troubleshooting Notes + +Use these when demo prep fails: + +- If a seeded student cannot log in, rerun `backend\venv\Scripts\python.exe backend\scripts\reseed_demo.py` before changing rows manually. +- If the student smoke stays on `/login`, verify the frontend is pointed at the intended backend and that the reseed completed against the same database the backend is using. +- If `npm` is blocked by PowerShell execution policy or npm cache issues, use the direct `cmd /c` binary commands shown above. +- If the dashboard loads but the flagship course is missing, treat that as a failed demo state rather than a cosmetic issue. +- If the lesson route opens but governed lesson restoration fails, stop and verify backend runtime integrity before changing demo data manually. + +## Recovery Steps + +1. Reapply migrations. +2. Rerun the deterministic reseed. +3. Restart backend and frontend. +4. Re-run the automated confidence layer. +5. Recheck the student flagship walkthrough with `normalstudent`. +6. Recheck the teacher dashboard with `teacher`. +7. Treat any need for manual row cleanup, reenrollment, or attempt deletion as a demo-readiness failure. ## Guardrails -- No frontend UI changes are required for demo preparation. -- No auth bypass is used. -- No fake scoring is used. -- No special-case runtime logic is required in the demo path. -- No workflow expansion is part of this setup path. +- no auth bypass is used +- no governed runtime protections are disabled +- no manual database repair is part of the expected startup path +- no broader CI or deployment automation changes are required for this runbook diff --git a/docs/demo-smoke-validation.md b/docs/demo-smoke-validation.md index 97ca1db..6a60da9 100644 --- a/docs/demo-smoke-validation.md +++ b/docs/demo-smoke-validation.md @@ -2,6 +2,11 @@ This runbook covers the bounded browser smoke flow for the seeded EchoEd demo environment. It is intentionally limited to one deterministic student flagship path in Phase 1. +Related runbooks: + +- [Demo Readiness Runbook](./demo-readiness.md) +- [Pilot Verification Checklist](./pilot-verification-checklist.md) + ## Purpose Use this smoke flow to confirm that the seeded demo environment is usable in a real browser before a live demo. It is not a full end-to-end suite and it does not replace backend or component-level verification. @@ -12,6 +17,17 @@ Use this smoke flow to confirm that the seeded demo environment is usable in a r - Frontend dependencies are installed, including Playwright. - The deterministic demo seed has been applied successfully. - The frontend app is available at `http://127.0.0.1:4200` unless you override `PLAYWRIGHT_BASE_URL`. +- The backend API is reachable through the frontend proxy at `http://127.0.0.1:8000`. + +## Recommended Preflight + +Before running the smoke, confirm these conditions: + +- `backend\venv\Scripts\python.exe backend\scripts\reseed_demo.py` completes successfully. +- The backend is serving authenticated routes without migration errors. +- The frontend login page loads at `/login`. +- The demo credentials still work for `normalstudent` / `password`. +- You are not relying on a stale browser session from a previous run. ## Prepare the Demo Environment @@ -36,6 +52,8 @@ backend\venv\Scripts\python.exe backend\scripts\reseed_demo.py Make the API available locally at `http://127.0.0.1:8000`. +If you use the local FastAPI startup flow, start it from `backend` with your normal development command before launching the frontend. + ### 4. Start the frontend From `frontend`: @@ -63,6 +81,12 @@ From `frontend`: npm run smoke:demo:student ``` +Windows fallback when `npm` is blocked by PowerShell execution policy or npm cache issues: + +```powershell +cmd /c .\node_modules\.bin\playwright.cmd test tests/demo/student-flagship-smoke.spec.ts +``` + Optional overrides: ```powershell @@ -102,29 +126,45 @@ Fail means one of these is broken: This smoke flow is intentionally bounded. A pass does not imply exhaustive product coverage. -## Current Execution Status - -Browser-level execution is environment-blocked in this workspace until Playwright Chromium can be installed successfully. The current blocker is local disk-space exhaustion during `npx playwright install chromium`. - ## Troubleshooting If browser setup or execution fails: -1. Check free disk space and make sure there is enough room for the Playwright Chromium download. -2. Clear Angular cache if local build artifacts have accumulated: +1. If login fails and the browser remains on `/login`, rerun the deterministic reseed: + +```powershell +backend\venv\Scripts\python.exe backend\scripts\reseed_demo.py +``` + +2. Check that the frontend is pointing at the intended API and that the backend is actually running. +3. If `npm run smoke:demo:student` fails under PowerShell because script execution is disabled, run: + +```powershell +cmd /c .\node_modules\.bin\playwright.cmd test tests/demo/student-flagship-smoke.spec.ts +``` + +4. Check free disk space and make sure there is enough room for Playwright browser binaries and frontend build artifacts. +5. Clear Angular cache if local build artifacts have accumulated: ```powershell npx ng cache clean ``` -3. Retry the browser install from `frontend`: +6. Retry the browser install from `frontend`: ```powershell npx playwright install chromium ``` -4. Rerun the smoke: +7. Rerun the smoke: ```powershell npm run smoke:demo:student ``` + +8. If the smoke still fails after reseed, treat that as a pilot-readiness failure until you can explain whether the issue is: + +- auth/login +- dashboard continuation +- governed lesson restoration +- local startup/proxy drift diff --git a/docs/pilot-verification-checklist.md b/docs/pilot-verification-checklist.md new file mode 100644 index 0000000..8849fda --- /dev/null +++ b/docs/pilot-verification-checklist.md @@ -0,0 +1,91 @@ +# Pilot Verification Checklist + +This checklist is the bounded go/no-go flow for preparing EchoEd for a small pilot walkthrough. + +Related runbooks: + +- [Demo Readiness Runbook](./demo-readiness.md) +- [Demo Smoke Validation](./demo-smoke-validation.md) + +## Before You Start + +- confirm backend dependencies are installed +- confirm frontend dependencies and Playwright are installed +- confirm the backend and frontend will run against the intended local environment + +## Reset and Startup + +1. Apply backend migrations. +2. Run `backend\venv\Scripts\python.exe backend\scripts\reseed_demo.py`. +3. Start the backend locally. +4. Start the frontend locally. +5. Open `http://127.0.0.1:4200/login` and confirm the login page renders. + +## Automated Verification + +Run these in order: + +```powershell +cd backend +pytest -q +``` + +```powershell +cd frontend +cmd /c .\node_modules\.bin\ng.cmd test --watch=false --browsers=ChromeHeadless +``` + +```powershell +cd frontend +cmd /c .\node_modules\.bin\playwright.cmd test tests/demo/student-flagship-smoke.spec.ts +``` + +Treat any failure here as a pilot-readiness failure until explained. + +## Manual Student Checks + +- sign in as `normalstudent` +- confirm the active flagship course card is visible +- confirm `Continue lesson` enters the governed lesson path +- confirm the lesson page loads with the governed viewer +- confirm the student can exit the lesson and return to the dashboard safely + +## Manual Teacher Checks + +- sign in as `teacher` +- confirm the dashboard loads without broken or empty shell states +- confirm the flagship course context is visible enough for a live walkthrough +- confirm learner/runtime context is visible enough to explain the archetype story + +These teacher checks remain intentionally manual in Phase 1. + +## Manual Admin/Governance Checks + +Only run these if the walkthrough needs governance visibility: + +- sign in as `orgadmin` +- confirm the flagship course remains present +- confirm staff-only governance reads remain staff-only +- confirm the environment does not require manual repair to expose the flagship governance story + +## Troubleshooting + +- If login fails after reseed, rerun the reseed before changing any data manually. +- If the smoke test stays on `/login`, check that the frontend and backend are pointed at the same local environment. +- If PowerShell blocks `npm`, use the direct `cmd /c` binary commands. +- If `npm` fails because of local cache or disk pressure, clear space and rerun the direct commands. +- If the student dashboard loads but the flagship course is missing, stop and treat the demo environment as invalid. + +## What This Checklist Proves + +- the deterministic demo can be restored without manual cleanup +- the backend and frontend regression suites are green +- the bounded student browser story still works +- the teacher dashboard is manually demonstrable + +## What This Checklist Does Not Prove + +- exhaustive teacher browser coverage +- full end-to-end resilience across every route +- deployment health outside the local pilot-prep environment +- broader operational monitoring or production SRE readiness diff --git a/frontend/src/app/app.routes.spec.ts b/frontend/src/app/app.routes.spec.ts new file mode 100644 index 0000000..f3df40e --- /dev/null +++ b/frontend/src/app/app.routes.spec.ts @@ -0,0 +1,85 @@ +import { Route, Routes } from '@angular/router'; +import { routes } from './app.routes'; +import { LearnerPortalComponent } from './pages/learner-portal/learner-portal.component'; +import { LearnerProductsComponent } from './pages/learner-portal/learner-products.component'; +import { LearnerResourcesComponent } from './pages/learner-portal/learner-resources.component'; +import { LessonViewComponent } from './pages/lesson-view.component'; + +function findRoute(routeList: Routes, path: string): Route | undefined { + const exactRoute = routeList.find(candidate => candidate.path === path); + if (exactRoute) { + return exactRoute; + } + + const [head, ...tail] = path.split('/'); + const route = routeList.find(candidate => candidate.path === head); + + if (!route) { + const passthrough = routeList.find(candidate => candidate.path === ''); + return passthrough?.children ? findRoute(passthrough.children, path) : undefined; + } + + if (tail.length === 0) { + return route; + } + + if (route.children) { + return findRoute(route.children, tail.join('/')); + } + + const passthrough = routeList.find(candidate => candidate.path === ''); + return passthrough?.children ? findRoute(passthrough.children, path) : undefined; +} + +describe('app routes', () => { + it('preserves the legacy authenticated home route', () => { + expect(findRoute(routes, 'home')).toBeTruthy(); + expect(findRoute(routes, 'home/')).toBeTruthy(); + }); + + it('adds V2 workspace aliases for platform navigation', () => { + [ + 'workspace', + 'workspace/projects', + 'workspace/projects/:projectId', + 'workspace/product-studio', + 'workspace/product-studio/create', + 'workspace/product-studio/courses/new', + 'workspace/product-studio/courses/:courseId/edit', + 'workspace/product-studio/generation-runs', + 'workspace/product-studio/generation-runs/:generationRunId', + 'workspace/products', + 'workspace/products/:productId', + 'workspace/knowledge-sources', + 'workspace/artifacts', + 'workspace/artifacts/:artifactId', + 'workspace/review-center', + 'workspace/access', + 'workspace/learners', + 'workspace/analytics', + 'workspace/settings', + ].forEach(path => expect(findRoute(routes, path)).withContext(path).toBeTruthy()); + }); + + it('adds learner portal aliases without removing existing learner pages', () => { + [ + 'learn', + 'learn/', + 'learn/products', + 'learn/paths', + 'learn/certificates', + 'learn/resources', + 'learn/lesson/:id', + ].forEach(path => expect(findRoute(routes, path)).withContext(path).toBeTruthy()); + }); + + it('maps Learner Portal V2 routes to V2 pages while reusing lesson runtime', () => { + const learnRoute = findRoute(routes, 'learn'); + const learnerShell = learnRoute?.children?.find(route => route.path === ''); + expect(learnerShell?.children?.find(route => route.path === '')?.component).toBe(LearnerPortalComponent); + expect(learnerShell?.children?.find(route => route.path === 'products')?.component).toBe(LearnerProductsComponent); + expect(learnerShell?.children?.find(route => route.path === 'resources')?.component).toBe(LearnerResourcesComponent); + expect(learnerShell?.children?.find(route => route.path === 'lesson/:id')?.component).toBe(LessonViewComponent); + expect(findRoute(routes, 'home/lesson/:id')?.component).toBe(LessonViewComponent); + }); +}); diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 4ba486d..064c53f 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -23,6 +23,23 @@ import { HomeSessionGuard } from './guards/home-session.guard'; import { ProgramsComponent } from './pages/programs/programs.component'; import { AssessmentDetailComponent } from './pages/assessment-detail/assessment-detail.component'; import { CertificationsComponent } from './pages/certifications/certifications.component'; +import { V2PlatformPageComponent } from './pages/v2-platform/v2-platform-page.component'; +import { ProductStudioComponent } from './pages/product-studio/product-studio.component'; +import { V2CollectionPageComponent } from './pages/v2-platform/v2-collection-page.component'; +import { ProductDetailComponent } from './pages/product-detail/product-detail.component'; +import { ProjectDetailComponent } from './pages/project-detail/project-detail.component'; +import { ArtifactDetailComponent } from './pages/artifact-detail/artifact-detail.component'; +import { GenerationRunDetailComponent } from './pages/generation-run-detail/generation-run-detail.component'; +import { ReviewCenterComponent } from './pages/review-center/review-center.component'; +import { LearnerPortalComponent } from './pages/learner-portal/learner-portal.component'; +import { LearnerProductsComponent } from './pages/learner-portal/learner-products.component'; +import { LearnerResourcesComponent } from './pages/learner-portal/learner-resources.component'; +import { AccessGrantsComponent } from './pages/access-grants/access-grants.component'; +import { WorkspaceAnalyticsComponent } from './pages/workspace-analytics/workspace-analytics.component'; + +const creatorRoles = ['admin', 'teacher', 'content_admin', 'org_admin', 'instructor']; +const studioRoles = ['content_admin', 'org_admin']; +const sectionRoles = ['teacher', 'org_admin', 'instructor']; export const routes: Routes = [ { path: '', component: LandingComponent }, @@ -104,5 +121,227 @@ export const routes: Routes = [ } ] }, + { + path: 'workspace', + component: HomeComponent, + canActivate: [HomeSessionGuard], + children: [ + { + path: '', + component: V2PlatformPageComponent, + data: { + eyebrow: 'Workspace', + title: 'EchoEd workspace', + description: 'A platform view of your organization knowledge, learning products, review workflow, learners, and operational readiness.', + status: 'Phase 1 maps the existing education runtime into the V2 platform shell without replacing courses, lessons, progress, or governance.', + links: [ + { label: 'View products', route: '/workspace/products' }, + { label: 'Open learner portal', route: '/workspace/learners' }, + { label: 'Review center', route: '/workspace/review-center' } + ] + } + }, + { + path: 'projects', + component: V2CollectionPageComponent, + canActivate: [RoleGuard], + data: { + roles: creatorRoles, + eyebrow: 'Projects', + title: 'Project knowledge pipeline', + status: 'Real V2 project shell data. Creating projects is available through Product Studio.', + emptyText: 'Create your first project shell from Product Studio.', + collection: 'projects' + } + }, + { + path: 'projects/:projectId', + component: ProjectDetailComponent, + canActivate: [RoleGuard], + data: { roles: creatorRoles } + }, + { + path: 'product-studio', + component: ProductStudioComponent, + canActivate: [RoleGuard], + data: { roles: creatorRoles } + }, + { + path: 'product-studio/create', + component: ProductStudioComponent, + canActivate: [RoleGuard], + data: { roles: creatorRoles } + }, + { + path: 'product-studio/generation-runs', + component: V2CollectionPageComponent, + canActivate: [RoleGuard], + data: { + roles: creatorRoles, + eyebrow: 'Generation Runs', + title: 'AI generation attempts', + status: 'Read-only generation metadata. Execution is not implemented in Phase 3.', + emptyText: 'Generation run history will appear here after future AI execution workflows are implemented.', + collection: 'generation-runs' + } + }, + { + path: 'product-studio/generation-runs/:generationRunId', + component: GenerationRunDetailComponent, + canActivate: [RoleGuard], + data: { roles: creatorRoles } + }, + { + path: 'product-studio/courses', + component: StudioCoursesComponent, + canActivate: [RoleGuard], + data: { roles: studioRoles } + }, + { + path: 'product-studio/courses/new', + component: CourseWizardComponent, + canActivate: [RoleGuard], + data: { roles: ['admin', 'teacher'] } + }, + { + path: 'product-studio/courses/:courseId/edit', + component: CourseWizardComponent, + canActivate: [RoleGuard], + data: { roles: ['admin', 'teacher'] } + }, + { + path: 'products', + component: V2CollectionPageComponent, + data: { + eyebrow: 'Products', + title: 'Product catalog', + status: 'Real V2 product wrappers. Course-backed products link to the existing education runtime.', + emptyText: 'Create product shells from Product Studio, or run the Phase 2 backfill migration for existing courses.', + collection: 'products' + } + }, + { + path: 'products/:productId', + component: ProductDetailComponent, + }, + { + path: 'products/manage', + component: AdminCoursesComponent, + canActivate: [RoleGuard], + data: { roles: ['admin'] } + }, + { + path: 'knowledge-sources', + component: V2CollectionPageComponent, + canActivate: [RoleGuard], + data: { + roles: creatorRoles, + eyebrow: 'Knowledge Sources', + title: 'Source-aware knowledge intake', + status: 'Read-only knowledge source wrappers. Source import and management arrive in a later phase.', + emptyText: 'No project-level knowledge sources exist yet.', + collection: 'knowledge-sources' + } + }, + { + path: 'artifacts', + component: V2CollectionPageComponent, + canActivate: [RoleGuard], + data: { + roles: creatorRoles, + eyebrow: 'Artifacts', + title: 'Generated documentation and learning assets', + status: 'Read-only artifact wrappers. Review, packaging, and publishing arrive in later phases.', + emptyText: 'No generated or uploaded artifacts exist yet.', + collection: 'artifacts' + } + }, + { + path: 'artifacts/:artifactId', + component: ArtifactDetailComponent, + canActivate: [RoleGuard], + data: { roles: creatorRoles } + }, + { + path: 'review-center', + component: ReviewCenterComponent, + canActivate: [RoleGuard], + data: { roles: creatorRoles } + }, + { + path: 'access', + component: AccessGrantsComponent, + canActivate: [RoleGuard], + data: { roles: creatorRoles } + }, + { + path: 'learners', + component: UserDashboardComponent, + children: [ + { path: '', component: EchoedRoleSelectorComponent }, + { path: 'products', component: AvailableCoursesComponent }, + { path: 'paths', component: ProgramsComponent }, + { path: 'certificates', component: CertificationsComponent }, + { + path: 'users', + component: AdminUsersComponent, + canActivate: [RoleGuard], + data: { roles: ['admin'] } + }, + { + path: 'cohorts', + component: SectionsComponent, + canActivate: [RoleGuard], + data: { roles: sectionRoles } + }, + { + path: 'cohorts/:id', + component: SectionDetailComponent, + canActivate: [RoleGuard], + data: { roles: sectionRoles } + } + ] + }, + { + path: 'analytics', + component: WorkspaceAnalyticsComponent, + canActivate: [RoleGuard], + data: { roles: creatorRoles } + }, + { path: 'settings', component: PreferencesComponent }, + { + path: 'settings/invites', + component: OrgInvitesComponent, + canActivate: [RoleGuard], + data: { roles: ['org_admin', 'content_admin'] } + }, + { + path: 'settings/badges', + component: AdminBadgesComponent, + canActivate: [RoleGuard], + data: { roles: ['admin'] } + }, + { path: 'lesson/:id', component: LessonViewComponent }, + ] + }, + { + path: 'learn', + component: HomeComponent, + canActivate: [HomeSessionGuard], + children: [ + { + path: '', + component: UserDashboardComponent, + children: [ + { path: '', component: LearnerPortalComponent }, + { path: 'products', component: LearnerProductsComponent }, + { path: 'paths', component: ProgramsComponent }, + { path: 'certificates', component: CertificationsComponent }, + { path: 'resources', component: LearnerResourcesComponent }, + { path: 'lesson/:id', component: LessonViewComponent }, + ] + } + ] + }, { path: '**', redirectTo: '' } ]; diff --git a/frontend/src/app/components/echo-header/echo-header.component.html b/frontend/src/app/components/echo-header/echo-header.component.html index c002bfc..d3b4d2d 100644 --- a/frontend/src/app/components/echo-header/echo-header.component.html +++ b/frontend/src/app/components/echo-header/echo-header.component.html @@ -1,17 +1,17 @@ -
-
-
+
+
+
EE
-
+

EchoEd Dashboard

-

Command center

- +

Command center

+
- +
-
-