From 52532ce00f0422870f2f4b0d94dd0b54072dd4ef Mon Sep 17 00:00:00 2001 From: kcar Date: Wed, 27 May 2026 03:59:26 +0100 Subject: [PATCH] =?UTF-8?q?feat(phase-c):=20lesson=20plans=20library=20bac?= =?UTF-8?q?kend=20=E2=80=94=20CRUD,=20delivery=20linking,=20AI=20suggest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds lesson_plans_router.py with 10 endpoints under /lessons/plans: GET/POST /plans, GET/PATCH/DELETE /plans/{id}, POST /plans/{id}/deliver, GET /plans/{id}/deliveries, POST/DELETE /plans/{id}/collaborators, POST /plans/{id}/suggest (Ollama-backed per-field AI suggestions). objectives and activities stored as JSONB arrays with Bloom taxonomy support. Registers router in run/routers.py. Adds seed_test_environment.py for platform-admin triggered reset + seed of demo users and Neo4j. Co-Authored-By: Claude Sonnet 4.6 --- routers/database/tools/lesson_plans_router.py | 650 ++++++++++++++++++ run/initialization/seed_test_environment.py | 408 +++++++++++ run/routers.py | 2 + 3 files changed, 1060 insertions(+) create mode 100644 routers/database/tools/lesson_plans_router.py create mode 100644 run/initialization/seed_test_environment.py diff --git a/routers/database/tools/lesson_plans_router.py b/routers/database/tools/lesson_plans_router.py new file mode 100644 index 0000000..77143fc --- /dev/null +++ b/routers/database/tools/lesson_plans_router.py @@ -0,0 +1,650 @@ +import os +import uuid +import aiohttp +from datetime import datetime +from typing import Any, Dict, List, Optional +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from modules.logger_tool import initialise_logger +from modules.auth.supabase_bearer import SupabaseBearer +from modules.database.supabase.utils.client import SupabaseServiceRoleClient + +logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True) +router = APIRouter() + +_OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434") +_OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3") +_LLM_TIMEOUT = 60 + +VALID_STATUS = {"draft", "ready", "archived"} +VALID_FIELDS = {"objectives", "activity_description", "title"} +BLOOM_LEVELS = {"remember", "understand", "apply", "analyse", "evaluate", "create"} + + +# ─── Supabase helpers ───────────────────────────────────────────────────────── + +def _sb() -> SupabaseServiceRoleClient: + return SupabaseServiceRoleClient() + + +def _resolve_institute_id(user_id: str) -> Optional[str]: + try: + sb = _sb() + p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute() + return str((p.data or {}).get("school_id") or "") or None + except Exception: + return None + + +def _require_institute(user_id: str) -> str: + iid = _resolve_institute_id(user_id) + if not iid: + raise HTTPException(status_code=400, detail="User is not linked to a school") + return iid + + +def _is_creator(plan: Dict[str, Any], user_id: str) -> bool: + return str(plan.get("created_by", "")) == str(user_id) + + +def _can_edit_plan(sb: SupabaseServiceRoleClient, plan_id: str, user_id: str) -> bool: + try: + plan_res = ( + sb.supabase.table("planned_lessons") + .select("created_by") + .eq("id", plan_id) + .single() + .execute() + ) + if not plan_res.data: + return False + if str(plan_res.data.get("created_by", "")) == str(user_id): + return True + collab = ( + sb.supabase.table("lesson_collaborators") + .select("can_edit") + .eq("planned_lesson_id", plan_id) + .eq("profile_id", user_id) + .single() + .execute() + ) + return bool((collab.data or {}).get("can_edit", False)) + except Exception: + return False + + +# ─── Request models ─────────────────────────────────────────────────────────── + +class CreatePlanRequest(BaseModel): + title: str + class_id: Optional[str] = None + subject: Optional[str] = None + year_group: Optional[str] = None + estimated_duration_minutes: Optional[int] = None + objectives: Optional[List[Dict[str, Any]]] = None + activities: Optional[List[Dict[str, Any]]] = None + status: Optional[str] = "draft" + tags: Optional[List[str]] = None + topic_code: Optional[str] = None + whiteboard_room_id: Optional[str] = None + course_id: Optional[str] = None + sequence_number: Optional[int] = None + + +class UpdatePlanRequest(BaseModel): + title: Optional[str] = None + class_id: Optional[str] = None + subject: Optional[str] = None + year_group: Optional[str] = None + estimated_duration_minutes: Optional[int] = None + objectives: Optional[List[Dict[str, Any]]] = None + activities: Optional[List[Dict[str, Any]]] = None + status: Optional[str] = None + tags: Optional[List[str]] = None + topic_code: Optional[str] = None + whiteboard_room_id: Optional[str] = None + course_id: Optional[str] = None + sequence_number: Optional[int] = None + + +class DeliverPlanRequest(BaseModel): + taught_lesson_id: Optional[str] = None + class_id: Optional[str] = None + notes: Optional[str] = None + + +class AddCollaboratorRequest(BaseModel): + profile_id: str + can_edit: bool = True + + +class SuggestRequest(BaseModel): + field: str + context: Optional[str] = None + activity_section: Optional[str] = None + objective_texts: Optional[List[str]] = None + + +# ─── Endpoints ──────────────────────────────────────────────────────────────── + +@router.get("/plans") +async def list_plans( + class_id: Optional[str] = None, + status: Optional[str] = None, + subject: Optional[str] = None, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + sb = _sb() + + q = ( + sb.supabase.table("planned_lessons") + .select("*") + .eq("institute_id", institute_id) + ) + if class_id: + q = q.eq("class_id", class_id) + if status: + q = q.eq("status", status) + if subject: + q = q.eq("subject", subject) + + owned = q.eq("created_by", user_id).execute().data or [] + + collab_rows = ( + sb.supabase.table("lesson_collaborators") + .select("planned_lesson_id") + .eq("profile_id", user_id) + .execute() + .data or [] + ) + collab_plan_ids = [r["planned_lesson_id"] for r in collab_rows] + + collab_plans: List[Dict] = [] + if collab_plan_ids: + q2 = ( + sb.supabase.table("planned_lessons") + .select("*") + .eq("institute_id", institute_id) + .in_("id", collab_plan_ids) + ) + if class_id: + q2 = q2.eq("class_id", class_id) + if status: + q2 = q2.eq("status", status) + if subject: + q2 = q2.eq("subject", subject) + collab_plans = q2.execute().data or [] + + seen_ids: set = set() + all_plans: List[Dict] = [] + for p in owned + collab_plans: + if p["id"] not in seen_ids: + seen_ids.add(p["id"]) + all_plans.append(p) + + if not all_plans: + return {"plans": []} + + plan_ids = [p["id"] for p in all_plans] + + collab_counts: Dict[str, int] = {} + delivery_counts: Dict[str, int] = {} + + try: + cc = ( + sb.supabase.table("lesson_collaborators") + .select("planned_lesson_id") + .in_("planned_lesson_id", plan_ids) + .execute() + .data or [] + ) + for r in cc: + collab_counts[r["planned_lesson_id"]] = collab_counts.get(r["planned_lesson_id"], 0) + 1 + except Exception: + pass + + try: + dc = ( + sb.supabase.table("lesson_deliveries") + .select("planned_lesson_id") + .in_("planned_lesson_id", plan_ids) + .execute() + .data or [] + ) + for r in dc: + delivery_counts[r["planned_lesson_id"]] = delivery_counts.get(r["planned_lesson_id"], 0) + 1 + except Exception: + pass + + enriched = [ + { + **p, + "collaborator_count": collab_counts.get(p["id"], 0), + "delivery_count": delivery_counts.get(p["id"], 0), + "is_owner": str(p.get("created_by", "")) == str(user_id), + } + for p in all_plans + ] + return {"plans": enriched} + + +@router.post("/plans") +async def create_plan( + body: CreatePlanRequest, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + sb = _sb() + + if body.status and body.status not in VALID_STATUS: + raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATUS}") + + row: Dict[str, Any] = { + "created_by": user_id, + "institute_id": institute_id, + "title": body.title, + "status": body.status or "draft", + "objectives": body.objectives or [], + "activities": body.activities or [], + "tags": body.tags or [], + } + for field in ( + "class_id", "subject", "year_group", "estimated_duration_minutes", + "topic_code", "whiteboard_room_id", "course_id", "sequence_number", + ): + val = getattr(body, field) + if val is not None: + row[field] = val + + res = sb.supabase.table("planned_lessons").insert(row).execute() + plan = (res.data or [{}])[0] + logger.info(f"Lesson plan created: {plan.get('id')} '{body.title}' by {user_id}") + return plan + + +@router.get("/plans/{plan_id}") +async def get_plan( + plan_id: str, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + sb = _sb() + + plan_res = ( + sb.supabase.table("planned_lessons") + .select("*") + .eq("id", plan_id) + .eq("institute_id", institute_id) + .single() + .execute() + ) + if not plan_res.data: + raise HTTPException(status_code=404, detail="Lesson plan not found") + + plan = plan_res.data + if not _is_creator(plan, user_id): + collab_check = ( + sb.supabase.table("lesson_collaborators") + .select("can_edit") + .eq("planned_lesson_id", plan_id) + .eq("profile_id", user_id) + .execute() + .data + ) + if collab_check is None: + raise HTTPException(status_code=403, detail="Access denied") + + collab_rows = ( + sb.supabase.table("lesson_collaborators") + .select("profile_id, can_edit, added_at") + .eq("planned_lesson_id", plan_id) + .execute() + .data or [] + ) + + profile_ids = [r["profile_id"] for r in collab_rows] + profile_map: Dict[str, Dict] = {} + if profile_ids: + try: + profiles = ( + sb.supabase.table("profiles") + .select("id, full_name, email") + .in_("id", profile_ids) + .execute() + .data or [] + ) + profile_map = {p["id"]: p for p in profiles} + except Exception: + pass + + collaborators = [ + { + **r, + "full_name": profile_map.get(r["profile_id"], {}).get("full_name"), + "email": profile_map.get(r["profile_id"], {}).get("email"), + } + for r in collab_rows + ] + + deliveries = ( + sb.supabase.table("lesson_deliveries") + .select("*") + .eq("planned_lesson_id", plan_id) + .order("started_at", desc=True) + .limit(10) + .execute() + .data or [] + ) + + return {**plan, "collaborators": collaborators, "recent_deliveries": deliveries} + + +@router.patch("/plans/{plan_id}") +async def update_plan( + plan_id: str, + body: UpdatePlanRequest, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + sb = _sb() + + if not _can_edit_plan(sb, plan_id, user_id): + raise HTTPException(status_code=403, detail="Access denied — not creator or editor collaborator") + + if body.status and body.status not in VALID_STATUS: + raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATUS}") + + updates: Dict[str, Any] = {} + for field in ( + "title", "class_id", "subject", "year_group", "estimated_duration_minutes", + "objectives", "activities", "status", "tags", "topic_code", + "whiteboard_room_id", "course_id", "sequence_number", + ): + val = getattr(body, field) + if val is not None: + updates[field] = val + + if not updates: + raise HTTPException(status_code=400, detail="Nothing to update") + + updates["updated_at"] = datetime.utcnow().isoformat() + res = ( + sb.supabase.table("planned_lessons") + .update(updates) + .eq("id", plan_id) + .eq("institute_id", institute_id) + .execute() + ) + if not res.data: + raise HTTPException(status_code=404, detail="Plan not found") + return res.data[0] + + +@router.delete("/plans/{plan_id}") +async def delete_plan( + plan_id: str, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + sb = _sb() + + plan_res = ( + sb.supabase.table("planned_lessons") + .select("created_by") + .eq("id", plan_id) + .eq("institute_id", institute_id) + .single() + .execute() + ) + if not plan_res.data: + raise HTTPException(status_code=404, detail="Plan not found") + if not _is_creator(plan_res.data, user_id): + raise HTTPException(status_code=403, detail="Only the creator can delete a plan") + + sb.supabase.table("lesson_collaborators").delete().eq("planned_lesson_id", plan_id).execute() + sb.supabase.table("planned_lessons").delete().eq("id", plan_id).execute() + logger.info(f"Lesson plan deleted: {plan_id} by {user_id}") + return {"status": "ok"} + + +@router.post("/plans/{plan_id}/deliver") +async def deliver_plan( + plan_id: str, + body: DeliverPlanRequest, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + sb = _sb() + + plan_res = ( + sb.supabase.table("planned_lessons") + .select("id, whiteboard_room_id") + .eq("id", plan_id) + .eq("institute_id", institute_id) + .single() + .execute() + ) + if not plan_res.data: + raise HTTPException(status_code=404, detail="Plan not found") + + if not _can_edit_plan(sb, plan_id, user_id): + raise HTTPException(status_code=403, detail="Access denied") + + row: Dict[str, Any] = { + "planned_lesson_id": plan_id, + "delivered_by": user_id, + "institute_id": institute_id, + "started_at": datetime.utcnow().isoformat(), + } + if body.taught_lesson_id: + row["taught_lesson_id"] = body.taught_lesson_id + if body.class_id: + row["class_id"] = body.class_id + if body.notes: + row["notes"] = body.notes + + whiteboard_room_id = plan_res.data.get("whiteboard_room_id") + if whiteboard_room_id: + row["whiteboard_room_id"] = whiteboard_room_id + + res = sb.supabase.table("lesson_deliveries").insert(row).execute() + delivery = (res.data or [{}])[0] + logger.info(f"Lesson delivery created: {delivery.get('id')} for plan {plan_id} by {user_id}") + return delivery + + +@router.get("/plans/{plan_id}/deliveries") +async def list_deliveries( + plan_id: str, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + sb = _sb() + + plan_res = ( + sb.supabase.table("planned_lessons") + .select("created_by") + .eq("id", plan_id) + .eq("institute_id", institute_id) + .single() + .execute() + ) + if not plan_res.data: + raise HTTPException(status_code=404, detail="Plan not found") + + deliveries = ( + sb.supabase.table("lesson_deliveries") + .select("*") + .eq("planned_lesson_id", plan_id) + .order("started_at", desc=True) + .execute() + .data or [] + ) + return {"deliveries": deliveries} + + +@router.post("/plans/{plan_id}/collaborators") +async def add_collaborator( + plan_id: str, + body: AddCollaboratorRequest, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + sb = _sb() + + plan_res = ( + sb.supabase.table("planned_lessons") + .select("created_by") + .eq("id", plan_id) + .eq("institute_id", institute_id) + .single() + .execute() + ) + if not plan_res.data: + raise HTTPException(status_code=404, detail="Plan not found") + if not _is_creator(plan_res.data, user_id): + raise HTTPException(status_code=403, detail="Only the creator can add collaborators") + + res = ( + sb.supabase.table("lesson_collaborators") + .upsert( + { + "planned_lesson_id": plan_id, + "profile_id": body.profile_id, + "can_edit": body.can_edit, + "added_at": datetime.utcnow().isoformat(), + }, + on_conflict="planned_lesson_id,profile_id", + ) + .execute() + ) + return {"status": "ok", "row": (res.data or [{}])[0]} + + +@router.delete("/plans/{plan_id}/collaborators/{profile_id}") +async def remove_collaborator( + plan_id: str, + profile_id: str, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + sb = _sb() + + plan_res = ( + sb.supabase.table("planned_lessons") + .select("created_by") + .eq("id", plan_id) + .eq("institute_id", institute_id) + .single() + .execute() + ) + if not plan_res.data: + raise HTTPException(status_code=404, detail="Plan not found") + if not _is_creator(plan_res.data, user_id): + raise HTTPException(status_code=403, detail="Only the creator can remove collaborators") + + sb.supabase.table("lesson_collaborators").delete().eq("planned_lesson_id", plan_id).eq("profile_id", profile_id).execute() + return {"status": "ok"} + + +@router.post("/plans/{plan_id}/suggest") +async def suggest_field( + plan_id: str, + body: SuggestRequest, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + sb = _sb() + + if body.field not in VALID_FIELDS: + raise HTTPException(status_code=400, detail=f"field must be one of {VALID_FIELDS}") + + plan_res = ( + sb.supabase.table("planned_lessons") + .select("*") + .eq("id", plan_id) + .eq("institute_id", institute_id) + .single() + .execute() + ) + if not plan_res.data: + raise HTTPException(status_code=404, detail="Plan not found") + + if not _can_edit_plan(sb, plan_id, user_id): + raise HTTPException(status_code=403, detail="Access denied") + + plan = plan_res.data + subject = plan.get("subject") or "a UK secondary school subject" + year_group = plan.get("year_group") or "unspecified year group" + title = plan.get("title", "") + context = body.context or "" + + context_part = ("Teacher notes: " + context + "\n") if context else "" + if body.field == "objectives": + existing = "" + if body.objective_texts: + existing = "\n".join("- " + t for t in body.objective_texts) + objectives_part = ("Existing objectives:\n" + existing + "\n") if existing else "" + prompt = ( + "You are an expert UK secondary school teacher.\n" + "Lesson: '" + title + "', Subject: " + subject + ", Year: " + year_group + ".\n" + + objectives_part + context_part + + "Suggest ONE clear, measurable learning objective for this lesson using Bloom's taxonomy. " + "Write only the objective text, no bullet point or prefix." + ) + elif body.field == "activity_description": + section = body.activity_section or "Main" + prompt = ( + "You are an expert UK secondary school teacher.\n" + "Lesson: '" + title + "', Subject: " + subject + ", Year: " + year_group + ".\n" + "Activity section: " + section + ".\n" + + context_part + + "Write a concise, practical description (2-4 sentences) for a " + section + " activity " + "suitable for UK secondary pupils. Include what the teacher does and what pupils do." + ) + else: # title + prompt = ( + "You are an expert UK secondary school teacher.\n" + "Subject: " + subject + ", Year: " + year_group + ".\n" + + context_part + + "Suggest ONE engaging, clear lesson title for a UK secondary school lesson. " + "Write only the title, no explanation." + ) + + try: + payload = { + "model": _OLLAMA_MODEL, + "prompt": prompt, + "stream": False, + } + async with aiohttp.ClientSession() as session: + async with session.post( + f"{_OLLAMA_URL}/api/generate", + json=payload, + headers={"Content-Type": "application/json"}, + timeout=aiohttp.ClientTimeout(total=_LLM_TIMEOUT), + ) as resp: + if resp.status != 200: + body_text = await resp.text() + logger.error(f"Ollama suggest error ({resp.status}): {body_text}") + raise HTTPException(status_code=502, detail=f"LLM request failed: {resp.status}") + data = await resp.json() + suggestion = (data.get("response") or "").strip() + except HTTPException: + raise + except Exception as e: + logger.error(f"suggest_field LLM call failed for plan {plan_id}: {e}") + raise HTTPException(status_code=502, detail="LLM service unavailable") + + return {"suggestion": suggestion} diff --git a/run/initialization/seed_test_environment.py b/run/initialization/seed_test_environment.py new file mode 100644 index 0000000..e3f83e6 --- /dev/null +++ b/run/initialization/seed_test_environment.py @@ -0,0 +1,408 @@ +""" +Seed Test Environment — idempotent full-environment setup for CC development. + +Creates: + - kcar@kevlarai.com → platform super-admin (admin_profiles) + - KevlarAI school → already exists; adds 3 student users + - Greenfield Academy → new second school with full staff + students + +Run inside ccapi container: + python3 main.py --mode seed-test + +Or directly: + cd ~/api && python3 -c " + from run.initialization.seed_test_environment import seed_test_environment + import json; print(json.dumps(seed_test_environment(), indent=2)) + " +""" +import os +import time +import requests +import uuid +from typing import Dict, Any, Optional, List +from modules.logger_tool import initialise_logger + +logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True) + +# ─── Existing KevlarAI school ──────────────────────────────────────────────── +KEVLARAI_INSTITUTE_ID = "6585bf91-6ae8-4d72-ab54-cddf3ba4e648" +KEVLARAI_INSTITUTE_DB = "cc.institutes.6585bf916ae84d72ab54cddf3ba4e648" + +# ─── Second test school ────────────────────────────────────────────────────── +GREENFIELD_INSTITUTE_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" # deterministic UUID +GREENFIELD_URN = "TEST-GFA-001" +GREENFIELD_NAME = "Greenfield Academy" + +# ─── User definitions ──────────────────────────────────────────────────────── +# Format: email, password, username, full_name, display_name, user_type, role, institute_id +TEST_USERS: List[Dict] = [ + # ── KevlarAI students ──────────────────────────────────────────────────── + { + "email": "student1@kevlarai.com", + "password": "Student1@KevlarAI!", + "username": "student1.kevlarai", + "full_name": "Alice Nguyen", + "display_name": "Alice", + "user_type": "student", + "role": "student", + "institute_id": KEVLARAI_INSTITUTE_ID, + "institute_db": KEVLARAI_INSTITUTE_DB, + "metadata": {"year_group": "Year 10"}, + }, + { + "email": "student2@kevlarai.com", + "password": "Student2@KevlarAI!", + "username": "student2.kevlarai", + "full_name": "Ben Okafor", + "display_name": "Ben", + "user_type": "student", + "role": "student", + "institute_id": KEVLARAI_INSTITUTE_ID, + "institute_db": KEVLARAI_INSTITUTE_DB, + "metadata": {"year_group": "Year 10"}, + }, + { + "email": "student3@kevlarai.com", + "password": "Student3@KevlarAI!", + "username": "student3.kevlarai", + "full_name": "Chloe Park", + "display_name": "Chloe", + "user_type": "student", + "role": "student", + "institute_id": KEVLARAI_INSTITUTE_ID, + "institute_db": KEVLARAI_INSTITUTE_DB, + "metadata": {"year_group": "Year 11"}, + }, + # ── Greenfield Academy admin ───────────────────────────────────────────── + { + "email": "head@greenfieldacademy.test", + "password": "Admin@Greenfield1!", + "username": "head.greenfield", + "full_name": "Dr James Whitmore", + "display_name": "Dr Whitmore", + "user_type": "teacher", + "role": "school_admin", + "institute_id": GREENFIELD_INSTITUTE_ID, + "institute_db": None, # populated after school provisioning + "metadata": {}, + }, + # ── Greenfield teachers ────────────────────────────────────────────────── + { + "email": "physics@greenfieldacademy.test", + "password": "Teacher1@Greenfield1!", + "username": "physics.greenfield", + "full_name": "Priya Sharma", + "display_name": "Priya", + "user_type": "teacher", + "role": "teacher", + "institute_id": GREENFIELD_INSTITUTE_ID, + "institute_db": None, + "metadata": {"subject": "Physics"}, + }, + { + "email": "english@greenfieldacademy.test", + "password": "Teacher2@Greenfield1!", + "username": "english.greenfield", + "full_name": "Tom Bradley", + "display_name": "Tom", + "user_type": "teacher", + "role": "teacher", + "institute_id": GREENFIELD_INSTITUTE_ID, + "institute_db": None, + "metadata": {"subject": "English"}, + }, + # ── Greenfield students ────────────────────────────────────────────────── + { + "email": "alice@greenfieldacademy.test", + "password": "Student1@Greenfield1!", + "username": "alice.greenfield", + "full_name": "Alice Thornton", + "display_name": "Alice T", + "user_type": "student", + "role": "student", + "institute_id": GREENFIELD_INSTITUTE_ID, + "institute_db": None, + "metadata": {"year_group": "Year 9"}, + }, + { + "email": "bob@greenfieldacademy.test", + "password": "Student2@Greenfield1!", + "username": "bob.greenfield", + "full_name": "Bob Ivanov", + "display_name": "Bob", + "user_type": "student", + "role": "student", + "institute_id": GREENFIELD_INSTITUTE_ID, + "institute_db": None, + "metadata": {"year_group": "Year 9"}, + }, + { + "email": "carol@greenfieldacademy.test", + "password": "Student3@Greenfield1!", + "username": "carol.greenfield", + "full_name": "Carol Mensah", + "display_name": "Carol", + "user_type": "student", + "role": "student", + "institute_id": GREENFIELD_INSTITUTE_ID, + "institute_db": None, + "metadata": {"year_group": "Year 10"}, + }, +] + + +def seed_test_environment() -> Dict[str, Any]: + from modules.database.supabase.utils.client import SupabaseServiceRoleClient + from modules.database.services.provisioning_service import ProvisioningService + + sb_client = SupabaseServiceRoleClient() + supabase_url = os.environ["SUPABASE_URL"] + service_key = os.environ["SERVICE_ROLE_KEY"] + + headers = { + "apikey": service_key, + "Authorization": f"Bearer {service_key}", + "Content-Type": "application/json", + } + + def auth_get(path, params=None): + r = requests.get(f"{supabase_url}/auth/v1/admin{path}", headers=headers, params=params) + r.raise_for_status() + return r.json() + + def auth_post(path, data): + r = requests.post(f"{supabase_url}/auth/v1/admin{path}", headers=headers, json=data) + return r + + def sb_upsert(table, data, on_conflict): + h = {**headers, "Prefer": "resolution=merge-duplicates,return=representation"} + r = requests.post( + f"{supabase_url}/rest/v1/{table}", + headers=h, + json=data, + params={"on_conflict": on_conflict}, + ) + return r + + def sb_select(table, eq_col, eq_val): + r = requests.get( + f"{supabase_url}/rest/v1/{table}", + headers=headers, + params={"select": "*", eq_col: f"eq.{eq_val}"}, + ) + r.raise_for_status() + return r.json() + + errors: List[str] = [] + results: Dict[str, Any] = {"steps": {}} + + # ── Step 1: Ensure kcar is a platform super-admin ───────────────────────── + logger.info("Step 1: Platform super-admin setup...") + try: + kcar_id = "d9e1d1a9-04c4-4611-bb05-57babf4a9a28" # known from profiles + r = sb_upsert("admin_profiles", { + "id": kcar_id, + "email": "kcar@kevlarai.com", + "display_name": "Kevin Carroll", + "admin_role": "super_admin", + "is_super_admin": True, + "metadata": {"seeded": True}, + }, on_conflict="id") + if r.status_code in (200, 201): + logger.info(" kcar → admin_profiles super_admin ✓") + results["steps"]["super_admin"] = "ok" + else: + raise Exception(f"Upsert failed: {r.text[:200]}") + except Exception as e: + msg = f"super_admin setup: {e}" + logger.error(f" {msg}") + errors.append(msg) + results["steps"]["super_admin"] = "error" + + # ── Step 2: Provision Greenfield Academy ────────────────────────────────── + logger.info("Step 2: Greenfield Academy school provisioning...") + greenfield_db = None + try: + # Check if already exists + existing = sb_select("institutes", "id", GREENFIELD_INSTITUTE_ID) + if not existing: + # Determine neo4j_uuid_string (same sanitization as provisioning_service) + neo4j_uuid = GREENFIELD_INSTITUTE_ID.replace("-", "") + r = sb_upsert("institutes", { + "id": GREENFIELD_INSTITUTE_ID, + "name": GREENFIELD_NAME, + "urn": GREENFIELD_URN, + "status": "active", + "address": {"line1": "1 Academy Road", "city": "Testville", "postcode": "TE1 1ST"}, + "website": "https://greenfieldacademy.test", + "metadata": {"headteacher": "Dr James Whitmore", "seeded": True}, + "neo4j_uuid_string": neo4j_uuid, + }, on_conflict="id") + if r.status_code not in (200, 201): + raise Exception(f"Institute upsert: {r.text[:200]}") + logger.info(f" Greenfield Academy created [{GREENFIELD_INSTITUTE_ID[:8]}]") + + # Provision Neo4j DB + provisioner = ProvisioningService() + prov_result = provisioner.ensure_school(GREENFIELD_INSTITUTE_ID) + greenfield_db = prov_result.get("db_name") + logger.info(f" Neo4j DB provisioned: {greenfield_db}") + else: + neo4j_uuid = existing[0].get("neo4j_uuid_string") or GREENFIELD_INSTITUTE_ID.replace("-", "") + greenfield_db = f"cc.institutes.{neo4j_uuid}" + logger.info(f" Greenfield Academy already exists → {greenfield_db}") + + results["steps"]["greenfield_school"] = greenfield_db + except Exception as e: + msg = f"greenfield_school: {e}" + logger.error(f" {msg}") + errors.append(msg) + results["steps"]["greenfield_school"] = "error" + greenfield_db = f"cc.institutes.{GREENFIELD_INSTITUTE_ID.replace('-', '')}" + + # Update institute_db for Greenfield users + for u in TEST_USERS: + if u["institute_id"] == GREENFIELD_INSTITUTE_ID: + u["institute_db"] = greenfield_db + + # ── Step 3: Create / verify all test users ──────────────────────────────── + logger.info("Step 3: Creating test users...") + created_users: Dict[str, Dict] = {} + try: + all_users = auth_get("/users", params={"per_page": 200}).get("users", []) + existing_by_email = {u["email"]: u for u in all_users} + except Exception as e: + msg = f"auth/users list: {e}" + logger.error(msg) + errors.append(msg) + existing_by_email = {} + + for spec in TEST_USERS: + email = spec["email"] + if email in existing_by_email: + uid = existing_by_email[email]["id"] + logger.info(f" {email}: exists [{uid[:8]}]") + created_users[email] = {"id": uid, **spec} + continue + + r = auth_post("/users", { + "email": email, + "password": spec["password"], + "email_confirm": True, + "user_metadata": { + "username": spec["username"], + "full_name": spec["full_name"], + "display_name": spec["display_name"], + "user_type": spec["user_type"], + }, + }) + if r.status_code in (200, 201): + uid = r.json()["id"] + logger.info(f" {email}: created [{uid[:8]}]") + created_users[email] = {"id": uid, **spec} + else: + msg = f"create {email}: {r.text[:200]}" + logger.error(f" {msg}") + errors.append(msg) + time.sleep(0.25) + + results["steps"]["users_created"] = list(created_users.keys()) + + # ── Step 4: Upsert profiles + memberships ───────────────────────────────── + logger.info("Step 4: Upserting profiles and memberships...") + for spec in TEST_USERS: + u = created_users.get(spec["email"]) + if not u: + continue + try: + sb_upsert("profiles", { + "id": u["id"], + "email": spec["email"], + "user_type": spec["user_type"], + "username": spec["username"], + "full_name": spec["full_name"], + "display_name": spec["display_name"], + "school_id": spec["institute_id"], + "neo4j_sync_status": "pending", + }, on_conflict="id") + + sb_upsert("institute_memberships", { + "profile_id": u["id"], + "institute_id": spec["institute_id"], + "role": spec["role"], + "metadata": spec.get("metadata", {}), + }, on_conflict="profile_id,institute_id") + except Exception as e: + msg = f"profile/membership {spec['email']}: {e}" + logger.error(f" {msg}") + errors.append(msg) + + results["steps"]["profiles_memberships"] = "ok" + + # ── Step 5: Neo4j Teacher/Student nodes for all users ──────────────────── + logger.info("Step 5: Creating Neo4j worker nodes...") + try: + from neo4j import GraphDatabase + driver = GraphDatabase.driver("bolt://192.168.0.209:7687", auth=("neo4j", "&%N304j&%")) + + # Group users by institute DB + by_db: Dict[str, List[Dict]] = {} + for spec in TEST_USERS: + u = created_users.get(spec["email"]) + if not u or not spec.get("institute_db"): + continue + by_db.setdefault(spec["institute_db"], []).append({**spec, "uid": u["id"]}) + + for db, users in by_db.items(): + with driver.session(database=db) as s: + for u in users: + label = "Teacher" if u["user_type"] == "teacher" else "Student" + s.run( + f"MERGE (n:{label} {{uuid_string: $uid}}) " + "SET n.worker_email = $email, " + " n.worker_name = $name, " + " n.unique_id = $uid, " + " n.user_type = $user_type, " + " n.worker_type = $user_type", + uid=u["uid"], email=u["email"], + name=u["full_name"], user_type=u["user_type"], + ) + logger.info(f" [{db[:30]}] {label}: {u['email']}") + + driver.close() + results["steps"]["neo4j_nodes"] = "ok" + except Exception as e: + msg = f"neo4j_nodes: {e}" + logger.error(f" {msg}") + errors.append(msg) + results["steps"]["neo4j_nodes"] = "error" + + # ── Summary ─────────────────────────────────────────────────────────────── + results["success"] = len(errors) == 0 + results["errors"] = errors + results["message"] = ( + f"Seed complete — {len(created_users)} users across 2 schools" + if not errors + else f"{len(errors)} error(s): {errors[0]}" + ) + + # Print credential sheet + logger.info("\n" + "=" * 60) + logger.info("TEST CREDENTIAL SHEET") + logger.info("=" * 60) + logger.info(f"{'ROLE':<20} {'EMAIL':<40} {'PASSWORD'}") + logger.info("-" * 90) + logger.info(f"{'[PLATFORM ADMIN]':<20} {'kcar@kevlarai.com':<40} KevlarAI2025!") + logger.info("-" * 90) + logger.info(f"[KevlarAI School]") + for spec in TEST_USERS: + if spec["institute_id"] == KEVLARAI_INSTITUTE_ID: + logger.info(f" {spec['role']:<18} {spec['email']:<40} {spec['password']}") + logger.info("-" * 90) + logger.info(f"[Greenfield Academy]") + for spec in TEST_USERS: + if spec["institute_id"] == GREENFIELD_INSTITUTE_ID: + logger.info(f" {spec['role']:<18} {spec['email']:<40} {spec['password']}") + logger.info("=" * 60) + + return results diff --git a/run/routers.py b/run/routers.py index 703c7ba..9290ad1 100644 --- a/run/routers.py +++ b/run/routers.py @@ -17,6 +17,7 @@ from routers.database.tools.classes_router import router as classes_router from routers.database.tools.taught_lessons_router import router as taught_lessons_router from routers.database.tools.invitations_router import router as invitations_router from routers.database.tools.platform_admin_router import router as platform_admin_router +from routers.database.tools.lesson_plans_router import router as lesson_plans_router from routers.database.files import cabinets as cabinets_router from routers.database.files import files as files_router from routers.simple_upload import router as simple_upload_router @@ -71,6 +72,7 @@ def register_routes(app: FastAPI): app.include_router(taught_lessons_router, prefix="/timetable", tags=["Taught Lessons"]) app.include_router(invitations_router, prefix="/users", tags=["People"]) app.include_router(platform_admin_router, prefix="/admin", tags=["Platform Admin"]) + app.include_router(lesson_plans_router, prefix="/lessons", tags=["Lesson Plans"]) app.include_router(default_nodes_router.router, prefix="/database/tools", tags=["Navigation"]) # Database Filesystem Routes