feat(phase-c): lesson plans library backend — CRUD, delivery linking, AI suggest
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 <noreply@anthropic.com>
This commit is contained in:
parent
abf8d05ca1
commit
52532ce00f
650
routers/database/tools/lesson_plans_router.py
Normal file
650
routers/database/tools/lesson_plans_router.py
Normal file
@ -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}
|
||||||
408
run/initialization/seed_test_environment.py
Normal file
408
run/initialization/seed_test_environment.py
Normal file
@ -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
|
||||||
@ -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.taught_lessons_router import router as taught_lessons_router
|
||||||
from routers.database.tools.invitations_router import router as invitations_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.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 cabinets as cabinets_router
|
||||||
from routers.database.files import files as files_router
|
from routers.database.files import files as files_router
|
||||||
from routers.simple_upload import router as simple_upload_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(taught_lessons_router, prefix="/timetable", tags=["Taught Lessons"])
|
||||||
app.include_router(invitations_router, prefix="/users", tags=["People"])
|
app.include_router(invitations_router, prefix="/users", tags=["People"])
|
||||||
app.include_router(platform_admin_router, prefix="/admin", tags=["Platform Admin"])
|
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"])
|
app.include_router(default_nodes_router.router, prefix="/database/tools", tags=["Navigation"])
|
||||||
|
|
||||||
# Database Filesystem Routes
|
# Database Filesystem Routes
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user