Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e269e67f27 | ||
|
|
77bb0766ff | ||
|
|
98be55ab57 | ||
|
|
62234dbbcb | ||
|
|
a1d297ac30 | ||
|
|
5ad9c01cde | ||
|
|
96f9fb2446 | ||
|
|
f52c3267ca | ||
|
|
6ce6272a1e | ||
|
|
b8cb9083ec | ||
|
|
8427063bd1 | ||
|
|
5f822eaf87 | ||
|
|
c690caa26d | ||
|
|
0ce654c6c6 | ||
|
|
4b296cff74 | ||
|
|
3711b52ea4 | ||
|
|
d3465eca7b | ||
|
|
9de949d212 | ||
|
|
f203f376e9 | ||
|
|
52f5ef4ca2 | ||
|
|
ead4452277 | ||
|
|
e66c8ec291 | ||
|
|
abc90fa1b6 | ||
|
|
39ad1818ae | ||
|
|
1738af0e3d | ||
|
|
7808a0ae56 | ||
|
|
47409c499e | ||
|
|
88a3193e01 | ||
|
|
4f6634e088 | ||
|
|
54760083b5 | ||
|
|
550d405935 | ||
|
|
df40ddc286 | ||
|
|
310e273aa5 | ||
|
|
7fede4d082 | ||
|
|
b452c9f593 | ||
|
|
647f41e421 | ||
|
|
9b49e92722 | ||
|
|
3beb8069d3 | ||
|
|
d5bda761d6 | ||
|
|
ef75f08392 | ||
|
|
0d828315bb | ||
|
|
b71995f4fb | ||
|
|
bf3df05632 | ||
|
|
b42b409bb2 | ||
|
|
caeee6c9e4 | ||
|
|
0596ee5e2c | ||
|
|
9c32887407 | ||
|
|
035ea17844 | ||
|
|
52532ce00f | ||
|
|
abf8d05ca1 | ||
|
|
7c75481245 | ||
|
|
e42cd09dea | ||
|
|
fe3d7a12c8 | ||
|
|
84f7fa9de1 |
+183
@@ -0,0 +1,183 @@
|
||||
# Classroom Copilot API - Environment Variables Template
|
||||
# Copy this file to .env and fill in the values
|
||||
|
||||
# =============================================================================
|
||||
# Server Configuration
|
||||
# =============================================================================
|
||||
PORT_OLLAMA=11434
|
||||
UVICORN_PORT=8000
|
||||
UVICORN_WORKERS=4
|
||||
UVICORN_TIMEOUT=120
|
||||
HOST_OLLAMA=http://localhost:11434
|
||||
OLLAMA_MODEL=llama3.2
|
||||
|
||||
# =============================================================================
|
||||
# Supabase Configuration
|
||||
# =============================================================================
|
||||
SUPABASE_URL=https://your-project.supabase.co
|
||||
ANON_KEY=your-supabase-anon-key
|
||||
SERVICE_ROLE_KEY=your-supabase-service-role-key
|
||||
|
||||
# =============================================================================
|
||||
# Authentication
|
||||
# =============================================================================
|
||||
JWT_SECRET=your-jwt-secret-min-32-chars
|
||||
|
||||
# =============================================================================
|
||||
# Admin Configuration (for initial setup)
|
||||
# =============================================================================
|
||||
ADMIN_EMAIL=[email protected]
|
||||
ADMIN_PASSWORD=change-me-immediately
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_NAME=Admin User
|
||||
ADMIN_DISPLAY_NAME=Administrator
|
||||
ADMIN_WORKER_EMAIL=[email protected]
|
||||
|
||||
# =============================================================================
|
||||
# Redis Configuration
|
||||
# =============================================================================
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_SSL=false
|
||||
REDIS_RECOVERY_ENABLED=true
|
||||
REDIS_MAX_RETRY_ATTEMPTS=3
|
||||
REDIS_HEALTH_CHECK_INTERVAL=30
|
||||
|
||||
# Dev/Prod/Test DB isolation
|
||||
REDIS_DB_DEV=0
|
||||
REDIS_DB_PROD=1
|
||||
REDIS_DB_TEST=2
|
||||
|
||||
# Task TTL (seconds)
|
||||
REDIS_TASK_TTL_DEV=3600
|
||||
REDIS_TASK_TTL_PROD=7200
|
||||
|
||||
# Persistence
|
||||
REDIS_PERSIST_DEV=false
|
||||
REDIS_PERSIST_PROD=true
|
||||
|
||||
# =============================================================================
|
||||
# OpenAI / LLM Configuration
|
||||
# =============================================================================
|
||||
OPENAI_API_KEY=your-openai-api-key
|
||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
|
||||
# =============================================================================
|
||||
# LangChain Configuration (optional)
|
||||
# =============================================================================
|
||||
LANGCHAIN_API_KEY=
|
||||
LANGCHAIN_PROJECT=classroom-copilot
|
||||
|
||||
# =============================================================================
|
||||
# Document Processing (Docling)
|
||||
# =============================================================================
|
||||
DOCLING_URL=http://localhost:8000
|
||||
DOCLING_TIMEOUT=300
|
||||
DOCLING_PDF_BACKEND=marker
|
||||
DOCLING_INCLUDE_IMAGES=false
|
||||
DOCLING_IMAGES_SCALE=1.0
|
||||
DOCLING_VLM_MODEL=
|
||||
DOCLING_VLM_TIMEOUT=300
|
||||
DOCLING_NO_OCR_BY_PAGE=false
|
||||
DOCLING_OCR_BY_PAGE=false
|
||||
DOCLING_VLM_BY_PAGE=false
|
||||
DOCLING_SPLIT_THRESHOLD=0.95
|
||||
DOCLING_USE_SPLIT_MAP=false
|
||||
DOCLING_PICTURE_DESCRIPTION_AREA_THRESHOLD=0.1
|
||||
|
||||
# Auto-processing flags
|
||||
AUTO_DOCLING_OCR=false
|
||||
AUTO_DOCLING_NO_OCR=false
|
||||
AUTO_DOCLING_VLM=false
|
||||
AUTO_DOCUMENT_ANALYSIS=false
|
||||
AUTO_PAGE_IMAGES=false
|
||||
AUTO_SPLIT_MAP_GENERATION=false
|
||||
AUTO_TIKA_PROCESSING=false
|
||||
|
||||
# =============================================================================
|
||||
# Document Processing (Tika)
|
||||
# =============================================================================
|
||||
TIKA_URL=http://localhost:9998
|
||||
TIKA_TIMEOUT=120
|
||||
|
||||
# =============================================================================
|
||||
# Queue Configuration
|
||||
# =============================================================================
|
||||
QUEUE_WORKERS=4
|
||||
QUEUE_MAX_MEMORY_MB=512
|
||||
QUEUE_MAX_USER_MEMORY_MB=128
|
||||
QUEUE_DOCLING_LIMIT=5
|
||||
QUEUE_DOCUMENT_ANALYSIS_LIMIT=3
|
||||
QUEUE_LLM_LIMIT=2
|
||||
QUEUE_PAGE_IMAGES_LIMIT=10
|
||||
QUEUE_SPLIT_MAP_LIMIT=5
|
||||
QUEUE_TIKA_LIMIT=5
|
||||
|
||||
# Upload processing
|
||||
UPLOAD_QUEUE_ENABLED=true
|
||||
UPLOAD_IMMEDIATE_PROCESSING=false
|
||||
UPLOAD_STATUS_POLLING_INTERVAL=5
|
||||
MAX_FILE_SIZE_MB=50
|
||||
|
||||
# =============================================================================
|
||||
# OCR Configuration
|
||||
# =============================================================================
|
||||
OCR_ENGINE=tesseract
|
||||
OCR_LANG=eng
|
||||
|
||||
# =============================================================================
|
||||
# Memory / Warning Thresholds
|
||||
# =============================================================================
|
||||
MEMORY_WARNING_THRESHOLD=80
|
||||
MEMORY_REJECT_THRESHOLD=95
|
||||
|
||||
# =============================================================================
|
||||
# TLSync Configuration
|
||||
# =============================================================================
|
||||
# Server-side secret used to sign short-lived TLSync auth tokens. Do not expose with a VITE_ prefix.
|
||||
TLSYNC_SECRET=change-me-server-side-only
|
||||
TLSYNC_TOKEN_TTL_SECONDS=300
|
||||
|
||||
# =============================================================================
|
||||
# CORS Configuration
|
||||
# =============================================================================
|
||||
CORS_SITE_URL=https://app.classroomcopilot.ai
|
||||
CORS_API_URL=https://api.classroomcopilot.ai
|
||||
CORS_GRAPH_URL=https://graph.classroomcopilot.ai
|
||||
|
||||
# =============================================================================
|
||||
# Logging
|
||||
# =============================================================================
|
||||
LOG_LEVEL=info
|
||||
LOG_PATH=/var/log/classroom-copilot
|
||||
|
||||
# =============================================================================
|
||||
# Development Mode
|
||||
# =============================================================================
|
||||
DEV_MODE=false
|
||||
BACKEND_DEV_MODE=false
|
||||
BACKEND_INIT_PATH=/app/init
|
||||
|
||||
# =============================================================================
|
||||
# App Metadata
|
||||
# =============================================================================
|
||||
APP_NAME=Classroom Copilot
|
||||
APP_VERSION=1.0.0
|
||||
APP_AUTHOR=KevlarAI
|
||||
APP_AUTHOR_EMAIL=[email protected]
|
||||
APP_DESCRIPTION=AI-powered classroom collaboration platform
|
||||
APP_PROTOCOL=https
|
||||
APP_BOLT_URL=bolt://neo4j:7687
|
||||
APP_GRAPH_URL=http://localhost:7474
|
||||
|
||||
# =============================================================================
|
||||
# External API Keys
|
||||
# =============================================================================
|
||||
YOUTUBE_API_KEY=
|
||||
GOOGLE_CLIENT_SECRETS_FILE=
|
||||
|
||||
# =============================================================================
|
||||
# Node Filesystem (for document storage)
|
||||
# =============================================================================
|
||||
NODE_FILESYSTEM_PATH=/tmp/cc-nodes
|
||||
@@ -0,0 +1,35 @@
|
||||
name: api-ci-deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test-build-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build API image
|
||||
run: docker build -t cc-api-ci:${{ github.sha }} .
|
||||
|
||||
- name: Configure SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
printf '%s\n' "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
|
||||
|
||||
- name: Deploy API
|
||||
run: |
|
||||
ssh -i ~/.ssh/deploy_key "${{ secrets.DEPLOY_USER }}@${{ secrets.API_DEPLOY_HOST }}" '
|
||||
set -euo pipefail
|
||||
cd /home/kcar/api
|
||||
git fetch origin master
|
||||
git reset --hard origin/master
|
||||
docker network inspect kevlarai-network >/dev/null 2>&1 || docker network create kevlarai-network
|
||||
docker compose -p api -f docker-compose.yml up -d --build
|
||||
docker compose -p api -f docker-compose.yml ps
|
||||
curl -fsS http://127.0.0.1:8000/health >/dev/null
|
||||
'
|
||||
+62
-4
@@ -1,11 +1,69 @@
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
.venv
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Environment files (never commit secrets)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
queue_workers.log
|
||||
|
||||
# Large files
|
||||
*.csv
|
||||
*.xlsx
|
||||
*.sqlite
|
||||
*.db
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
.archive/*
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
|
||||
data/logs/*
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Local environment variants
|
||||
.env.dev
|
||||
.env.prod
|
||||
|
||||
.archive/
|
||||
*.bak
|
||||
*.bak.*
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
services:
|
||||
# ── Required environment variables (see .env.example) ───────────────────────
|
||||
# APP_BOLT_URL, USER_NEO4J, PASSWORD_NEO4J — Neo4j connection
|
||||
# SUPABASE_URL, SERVICE_ROLE_KEY — Supabase project
|
||||
# REDIS_HOST, REDIS_PORT, REDIS_PASSWORD — Redis (optional auth)
|
||||
# FASTAPI_SECRET_KEY, ADMIN_EMAIL — API config
|
||||
redis-dev:
|
||||
image: redis:7-alpine
|
||||
container_name: cc-redis-dev
|
||||
ports:
|
||||
- "16379:6379"
|
||||
volumes:
|
||||
- redis-dev-data:/data
|
||||
command: redis-server --appendonly yes
|
||||
networks:
|
||||
- kevlarai-network
|
||||
healthcheck:
|
||||
test: [ "CMD", "redis-cli", "ping" ]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
init:
|
||||
image: cc-api-dev:latest
|
||||
container_name: api-init-dev
|
||||
env_file:
|
||||
- .env.dev
|
||||
environment:
|
||||
- REDIS_HOST=redis-dev
|
||||
- RUN_INIT=true
|
||||
- INIT_MODE=${INIT_MODE:-infra}
|
||||
- INIT_ONLY=true
|
||||
command: ["./docker-entrypoint.sh", "init-only"]
|
||||
depends_on:
|
||||
redis-dev:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- kevlarai-network
|
||||
profiles:
|
||||
- init
|
||||
|
||||
backend-dev:
|
||||
container_name: cc-api-dev
|
||||
image: cc-api-dev:latest
|
||||
env_file:
|
||||
- .env.dev
|
||||
environment:
|
||||
- REDIS_HOST=redis-dev
|
||||
- REDIS_DB_DEV=0
|
||||
- BACKEND_DEV_MODE=true
|
||||
- APP_ENV=development
|
||||
- ENVIRONMENT=development
|
||||
- START_MODE=dev
|
||||
- CC_COMPOSE_PROJECT=api-dev
|
||||
- CC_COMPOSE_SERVICE=backend-dev
|
||||
- RUN_INIT=false
|
||||
- INIT_MODE=infra
|
||||
ports:
|
||||
- "18000:8000"
|
||||
depends_on:
|
||||
redis-dev:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- kevlarai-network
|
||||
restart: unless-stopped
|
||||
|
||||
backend-test:
|
||||
image: cc-api-dev:latest
|
||||
env_file:
|
||||
- .env.dev
|
||||
environment:
|
||||
- REDIS_HOST=redis-dev
|
||||
- REDIS_DB_DEV=0
|
||||
- BACKEND_DEV_MODE=true
|
||||
- APP_ENV=development
|
||||
- ENVIRONMENT=development
|
||||
- START_MODE=dev
|
||||
- CC_COMPOSE_PROJECT=api-dev
|
||||
- CC_COMPOSE_SERVICE=backend-test
|
||||
- API_HEALTH_URL=http://192.168.0.64:18000/health
|
||||
depends_on:
|
||||
redis-dev:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- kevlarai-network
|
||||
entrypoint: ["python", "-m", "pytest"]
|
||||
command: ["-q", "tests"]
|
||||
profiles:
|
||||
- test
|
||||
|
||||
volumes:
|
||||
redis-dev-data:
|
||||
|
||||
networks:
|
||||
kevlarai-network:
|
||||
external: true
|
||||
name: kevlarai-network
|
||||
@@ -1,4 +1,9 @@
|
||||
services:
|
||||
# ── Required environment variables (see .env.example) ───────────────────────
|
||||
# APP_BOLT_URL, USER_NEO4J, PASSWORD_NEO4J — Neo4j connection
|
||||
# SUPABASE_URL, SERVICE_ROLE_KEY — Supabase project
|
||||
# REDIS_HOST, REDIS_PORT, REDIS_PASSWORD — Redis (optional auth)
|
||||
# FASTAPI_SECRET_KEY, ADMIN_EMAIL — API config
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: classroomcopilot-redis
|
||||
@@ -46,6 +51,11 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
- REDIS_HOST=redis
|
||||
- START_MODE=prod
|
||||
- APP_ENV=production
|
||||
- ENVIRONMENT=production
|
||||
- CC_COMPOSE_PROJECT=api
|
||||
- CC_COMPOSE_SERVICE=backend
|
||||
- RUN_INIT=${RUN_INIT:-false} # Set to 'true' to run init on startup
|
||||
- INIT_MODE=${INIT_MODE:-infra} # Which init tasks to run
|
||||
ports:
|
||||
|
||||
+18
-18
@@ -29,7 +29,7 @@ print_error() {
|
||||
|
||||
# Check if we should run initialization
|
||||
RUN_INIT="${RUN_INIT:-false}"
|
||||
INIT_MODE="${INIT_MODE:-infra}" # Default to 'infra', can be 'infra', 'full', or comma-separated list
|
||||
INIT_MODE="${INIT_MODE:-infra}" # Default to 'infra', can be 'infra', 'seed', 'seed-test', 'full', or comma-separated list
|
||||
|
||||
# If RUN_INIT is true, run initialization tasks
|
||||
if [ "$RUN_INIT" = "true" ]; then
|
||||
@@ -51,21 +51,21 @@ if [ "$RUN_INIT" = "true" ]; then
|
||||
}
|
||||
print_success "Infrastructure setup completed"
|
||||
;;
|
||||
"demo-school")
|
||||
print_status "Creating demo school..."
|
||||
python3 main.py --mode demo-school || {
|
||||
print_error "Demo school creation failed!"
|
||||
"seed")
|
||||
print_status "Seeding canonical full environment..."
|
||||
python3 main.py --mode seed || {
|
||||
print_error "Seed failed!"
|
||||
exit 1
|
||||
}
|
||||
print_success "Demo school creation completed"
|
||||
print_success "Seed completed"
|
||||
;;
|
||||
"demo-users")
|
||||
print_status "Creating demo users..."
|
||||
python3 main.py --mode demo-users || {
|
||||
print_error "Demo users creation failed!"
|
||||
"seed-test")
|
||||
print_status "Seeding lightweight test environment..."
|
||||
python3 main.py --mode seed-test || {
|
||||
print_error "Seed test failed!"
|
||||
exit 1
|
||||
}
|
||||
print_success "Demo users creation completed"
|
||||
print_success "Seed test completed"
|
||||
;;
|
||||
"gais-data")
|
||||
print_status "Importing GAIS data..."
|
||||
@@ -78,9 +78,7 @@ if [ "$RUN_INIT" = "true" ]; then
|
||||
"full")
|
||||
print_status "Running full initialization..."
|
||||
python3 main.py --mode infra || exit 1
|
||||
python3 main.py --mode demo-school || exit 1
|
||||
python3 main.py --mode demo-users || exit 1
|
||||
python3 main.py --mode gais-data || exit 1
|
||||
python3 main.py --mode seed || exit 1
|
||||
print_success "Full initialization completed"
|
||||
;;
|
||||
*)
|
||||
@@ -98,11 +96,13 @@ if [ "$RUN_INIT" = "true" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Start the production server (unless init-only mode)
|
||||
# Start the server (unless init-only mode). Default remains production, but
|
||||
# development Compose can set START_MODE=dev so cc-api-dev reports and uses
|
||||
# development Redis/config instead of silently booting as prod.
|
||||
START_MODE="${START_MODE:-prod}"
|
||||
if [ "$1" != "init-only" ] && [ -z "$INIT_ONLY" ]; then
|
||||
print_status "Starting production server..."
|
||||
exec ./start.sh prod
|
||||
print_status "Starting ${START_MODE} server..."
|
||||
exec ./start.sh "$START_MODE"
|
||||
else
|
||||
print_status "Init-only mode - not starting server"
|
||||
fi
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
# Helper script to run initialization tasks in production
|
||||
# Usage: ./init-production.sh [mode]
|
||||
# Modes: infra, demo-school, demo-users, gais-data, full
|
||||
# Modes: infra, seed, seed-test, gais-data, full
|
||||
|
||||
set -e
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH
|
||||
from fastapi import FastAPI, HTTPException
|
||||
import uvicorn
|
||||
import requests
|
||||
from urllib.parse import urlparse
|
||||
from typing import Dict, Any, Optional
|
||||
from modules.database.tools.neo4j_driver_tools import get_driver
|
||||
|
||||
@@ -22,15 +23,59 @@ from modules.queue_system import ServiceType
|
||||
app = FastAPI()
|
||||
setup_cors(app)
|
||||
|
||||
def _truthy_env(name: str) -> bool:
|
||||
return os.getenv(name, "").lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _runtime_environment() -> str:
|
||||
"""Return the API runtime role used for dev/prod backing-service selection."""
|
||||
start_mode = os.getenv("START_MODE", "prod").lower()
|
||||
if start_mode == "dev" or _truthy_env("BACKEND_DEV_MODE"):
|
||||
return "dev"
|
||||
return "prod"
|
||||
|
||||
|
||||
def _url_host(url: Optional[str]) -> Optional[str]:
|
||||
if not url:
|
||||
return None
|
||||
parsed = urlparse(url)
|
||||
return parsed.hostname
|
||||
|
||||
|
||||
def _runtime_identity() -> Dict[str, Any]:
|
||||
"""Non-secret runtime identity for agents and smoke tests.
|
||||
|
||||
This intentionally exposes only modes, labels, and URL hosts. It must not
|
||||
include API keys, passwords, bearer tokens, or full URLs that may embed
|
||||
credentials.
|
||||
"""
|
||||
return {
|
||||
"api_runtime_role": _runtime_environment(),
|
||||
"start_mode": os.getenv("START_MODE", "prod"),
|
||||
"app_environment": os.getenv("APP_ENV"),
|
||||
"environment": os.getenv("ENVIRONMENT"),
|
||||
"backend_dev_mode": _truthy_env("BACKEND_DEV_MODE"),
|
||||
"compose_project": os.getenv("COMPOSE_PROJECT_NAME") or os.getenv("CC_COMPOSE_PROJECT"),
|
||||
"compose_service": os.getenv("COMPOSE_SERVICE") or os.getenv("CC_COMPOSE_SERVICE"),
|
||||
"supabase_url_host": _url_host(os.getenv("SUPABASE_URL")),
|
||||
}
|
||||
|
||||
|
||||
# Health check endpoint
|
||||
@app.get("/health")
|
||||
async def health_check() -> Dict[str, Any]:
|
||||
"""Health check endpoint that verifies all service dependencies"""
|
||||
runtime_identity = _runtime_identity()
|
||||
health_status = {
|
||||
"status": "healthy",
|
||||
"runtime": runtime_identity,
|
||||
"services": {
|
||||
"neo4j": {"status": "healthy", "message": "Connected"},
|
||||
"supabase": {"status": "healthy", "message": "Connected"},
|
||||
"supabase": {
|
||||
"status": "healthy",
|
||||
"message": "Connected",
|
||||
"url_host": runtime_identity["supabase_url_host"],
|
||||
},
|
||||
"redis": {"status": "healthy", "message": "Connected"}
|
||||
}
|
||||
}
|
||||
@@ -45,9 +90,10 @@ async def health_check() -> Dict[str, Any]:
|
||||
}
|
||||
health_status["status"] = "unhealthy"
|
||||
except Exception as e:
|
||||
logger.warning(f"Neo4j health check failed: {e}")
|
||||
health_status["services"]["neo4j"] = {
|
||||
"status": "unhealthy",
|
||||
"message": f"Error checking Neo4j: {str(e)}"
|
||||
"message": "Error checking Neo4j"
|
||||
}
|
||||
health_status["status"] = "unhealthy"
|
||||
|
||||
@@ -67,9 +113,10 @@ async def health_check() -> Dict[str, Any]:
|
||||
}
|
||||
health_status["status"] = "unhealthy"
|
||||
except Exception as e:
|
||||
logger.warning(f"Supabase health check failed: {e}")
|
||||
health_status["services"]["supabase"] = {
|
||||
"status": "unhealthy",
|
||||
"message": f"Error checking Supabase Auth API: {str(e)}"
|
||||
"message": "Error checking Supabase Auth API"
|
||||
}
|
||||
health_status["status"] = "unhealthy"
|
||||
|
||||
@@ -77,8 +124,8 @@ async def health_check() -> Dict[str, Any]:
|
||||
# Check Redis using new Redis manager
|
||||
from modules.redis_manager import get_redis_manager
|
||||
|
||||
# Determine environment
|
||||
environment = 'dev' if os.getenv('BACKEND_DEV_MODE', 'true').lower() == 'true' else 'prod'
|
||||
# Determine environment from explicit startup/runtime identity.
|
||||
environment = runtime_identity["api_runtime_role"]
|
||||
redis_manager = get_redis_manager(environment)
|
||||
|
||||
# Get comprehensive health check
|
||||
@@ -96,9 +143,10 @@ async def health_check() -> Dict[str, Any]:
|
||||
health_status["status"] = "unhealthy"
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis health check failed: {e}")
|
||||
health_status["services"]["redis"] = {
|
||||
"status": "unhealthy",
|
||||
"message": f"Error checking Redis: {str(e)}"
|
||||
"message": "Error checking Redis"
|
||||
}
|
||||
health_status["status"] = "unhealthy"
|
||||
|
||||
@@ -244,33 +292,20 @@ def run_infrastructure_mode():
|
||||
logger.error(f"Infrastructure setup failed: {str(e)}")
|
||||
return False
|
||||
|
||||
def run_demo_school_mode():
|
||||
"""Run demo school creation"""
|
||||
logger.info("Running in demo school mode")
|
||||
logger.info("Starting demo school creation...")
|
||||
|
||||
def run_seed_mode(test: bool = False):
|
||||
"""Run canonical environment seed."""
|
||||
mode = "test" if test else "full"
|
||||
logger.info(f"Running canonical seed mode ({mode})")
|
||||
try:
|
||||
from run.initialization import initialize_demo_school_mode
|
||||
initialize_demo_school_mode()
|
||||
logger.info("Demo school creation completed successfully")
|
||||
return True
|
||||
from run.initialization.seed_environment import seed
|
||||
import json
|
||||
result = seed(test=test)
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
return bool(result.get('success'))
|
||||
except Exception as e:
|
||||
logger.error(f"Demo school creation failed: {str(e)}")
|
||||
logger.error(f"Seed mode failed: {str(e)}")
|
||||
return False
|
||||
|
||||
def run_demo_users_mode():
|
||||
"""Run demo users creation"""
|
||||
logger.info("Running in demo users mode")
|
||||
logger.info("Starting demo users creation...")
|
||||
|
||||
try:
|
||||
from run.initialization import initialize_demo_users_mode
|
||||
initialize_demo_users_mode()
|
||||
logger.info("Demo users creation completed successfully")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Demo users creation failed: {str(e)}")
|
||||
return False
|
||||
|
||||
def run_gais_data_mode():
|
||||
"""Run GAIS data import"""
|
||||
@@ -366,8 +401,8 @@ def parse_arguments():
|
||||
epilog="""
|
||||
Startup modes:
|
||||
infra - Setup infrastructure (Neo4j schema, calendar, Supabase buckets)
|
||||
demo-school - Create demo school (KevlarAI)
|
||||
demo-users - Create demo users
|
||||
seed - Seed canonical full environment (20 school users)
|
||||
seed-test - Seed lightweight test environment (9 school users)
|
||||
gais-data - Import GAIS data (Edubase, etc.)
|
||||
dev - Run development server with auto-reload
|
||||
prod - Run production server (for Docker/containerized deployment)
|
||||
@@ -376,7 +411,7 @@ Startup modes:
|
||||
|
||||
parser.add_argument(
|
||||
'--mode', '-m',
|
||||
choices=['infra', 'demo-school', 'demo-users', 'gais-data', 'dev', 'prod'],
|
||||
choices=['infra', 'seed', 'seed-test', 'gais-data', 'dev', 'prod'],
|
||||
default='dev',
|
||||
help='Startup mode (default: dev)'
|
||||
)
|
||||
@@ -399,16 +434,14 @@ if __name__ == "__main__":
|
||||
success = run_infrastructure_mode()
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
elif args.mode == 'demo-school':
|
||||
# Run demo school creation
|
||||
success = run_demo_school_mode()
|
||||
elif args.mode == 'seed':
|
||||
success = run_seed_mode(test=False)
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
elif args.mode == 'demo-users':
|
||||
# Run demo users creation
|
||||
success = run_demo_users_mode()
|
||||
|
||||
elif args.mode == 'seed-test':
|
||||
success = run_seed_mode(test=True)
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
elif args.mode == 'gais-data':
|
||||
# Run GAIS data import
|
||||
success = run_gais_data_mode()
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
FastAPI dependencies for platform-level admin access.
|
||||
|
||||
Two tiers:
|
||||
require_platform_admin — user must be in admin_profiles
|
||||
require_super_admin — user must have is_super_admin=True in admin_profiles
|
||||
|
||||
Usage:
|
||||
@router.get("/admin/schools")
|
||||
async def list_all_schools(admin=Depends(require_platform_admin)):
|
||||
...
|
||||
|
||||
@router.post("/admin/provision")
|
||||
async def provision(admin=Depends(require_super_admin)):
|
||||
...
|
||||
"""
|
||||
from fastapi import Depends, HTTPException
|
||||
from modules.auth.supabase_bearer import SupabaseBearer
|
||||
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
|
||||
|
||||
|
||||
def _sb() -> SupabaseServiceRoleClient:
|
||||
return SupabaseServiceRoleClient()
|
||||
|
||||
|
||||
async def require_platform_admin(
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> dict:
|
||||
"""Require the caller to be a registered platform admin (in admin_profiles)."""
|
||||
user_id = credentials.get("sub")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=403, detail="Invalid token")
|
||||
try:
|
||||
sb = _sb()
|
||||
result = (
|
||||
sb.supabase.table("admin_profiles")
|
||||
.select("id,admin_role,is_super_admin")
|
||||
.eq("id", user_id)
|
||||
.single()
|
||||
.execute()
|
||||
)
|
||||
if not result.data:
|
||||
raise HTTPException(status_code=403, detail="Platform admin access required")
|
||||
return {**credentials, "admin_profile": result.data}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(status_code=403, detail="Platform admin access required")
|
||||
|
||||
|
||||
async def require_super_admin(
|
||||
admin: dict = Depends(require_platform_admin),
|
||||
) -> dict:
|
||||
"""Require the caller to have is_super_admin=True."""
|
||||
if not admin.get("admin_profile", {}).get("is_super_admin"):
|
||||
raise HTTPException(status_code=403, detail="Super admin access required")
|
||||
return admin
|
||||
@@ -20,6 +20,10 @@ class SupabaseBearer(HTTPBearer):
|
||||
token = credentials.credentials
|
||||
# Decode using the string-based verifier to avoid async dependency conflicts
|
||||
payload = verify_supabase_jwt_str(token)
|
||||
# Keep the bearer token available to downstream dependencies that must
|
||||
# call Supabase as the user (RLS/storage policies), without requiring
|
||||
# each router to decode the Authorization header again.
|
||||
payload["_access_token"] = token
|
||||
return payload
|
||||
except Exception as e:
|
||||
logger.error(f"Token verification failed: {str(e)}")
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Neontology node schemas for the cc.public.exams knowledge graph.
|
||||
|
||||
cc.public.exams is a dedicated, shared, public Neo4j database — co-primary/authoritative for the
|
||||
exam knowledge graph (specs, spec-points, paper→question→part→region structure, ASSESSES links).
|
||||
Supabase remains source of truth for operational data (geometry, marks, submissions); the two
|
||||
layers join on shared UUIDs:
|
||||
|
||||
exam_questions.id <-> Question|Part.uuid_string (container -> Question, leaf -> Part)
|
||||
exam_response_areas.id <-> Region.uuid_string
|
||||
eb_exams.exam_code <-> ExamPaper.exam_code
|
||||
eb_specifications.spec_code <-> Specification.spec_code
|
||||
|
||||
Ownership: created by an infra-init step; read by all authenticated API calls; written by the API
|
||||
service role only (no direct client writes).
|
||||
"""
|
||||
from typing import ClassVar, Optional
|
||||
from ..base_nodes import CCBaseNode
|
||||
|
||||
|
||||
class ExamBaseNode(CCBaseNode):
|
||||
__primarylabel__: ClassVar[str] = ''
|
||||
|
||||
|
||||
class ExamBoardNode(ExamBaseNode):
|
||||
__primarylabel__: ClassVar[str] = 'ExamBoard'
|
||||
code: str # 'AQA'
|
||||
name: str
|
||||
|
||||
|
||||
class SpecificationNode(ExamBaseNode):
|
||||
__primarylabel__: ClassVar[str] = 'Specification'
|
||||
spec_code: str # 'AQA-PHYS-8463' (== Supabase eb_specifications.spec_code)
|
||||
exam_board_code: str
|
||||
subject_code: Optional[str] = None
|
||||
award_code: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
|
||||
|
||||
class SpecPointNode(ExamBaseNode):
|
||||
__primarylabel__: ClassVar[str] = 'SpecPoint'
|
||||
ref: str # '4.1', '4.2.1'
|
||||
description: str
|
||||
spec_code: str
|
||||
exam_board_code: str
|
||||
|
||||
|
||||
class ExamPaperNode(ExamBaseNode):
|
||||
__primarylabel__: ClassVar[str] = 'ExamPaper'
|
||||
exam_code: str # == Supabase eb_exams.exam_code
|
||||
spec_code: str
|
||||
paper_code: Optional[str] = None
|
||||
tier: Optional[str] = None
|
||||
session: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
page_count: Optional[int] = None
|
||||
|
||||
|
||||
class QuestionNode(ExamBaseNode): # roll-up container; uuid_string == exam_questions.id
|
||||
__primarylabel__: ClassVar[str] = 'Question'
|
||||
exam_code: str
|
||||
label: str # '01'
|
||||
order: int
|
||||
max_marks: float
|
||||
|
||||
|
||||
class PartNode(ExamBaseNode): # leaf; uuid_string == exam_questions.id
|
||||
__primarylabel__: ClassVar[str] = 'Part'
|
||||
exam_code: str
|
||||
label: str # '01.1'
|
||||
order: int
|
||||
max_marks: float
|
||||
answer_type: str
|
||||
mark_scheme_type: str
|
||||
|
||||
|
||||
class RegionNode(ExamBaseNode): # uuid_string == exam_response_areas.id
|
||||
__primarylabel__: ClassVar[str] = 'Region'
|
||||
page: int
|
||||
kind: str # 'response' | 'context'
|
||||
response_form: str
|
||||
|
||||
|
||||
# Relationship reference (written by the projection / linker, not modelled as classes here):
|
||||
# (:ExamBoard)-[:PUBLISHES]->(:Specification)
|
||||
# (:Specification)-[:HAS_SPEC_POINT]->(:SpecPoint)
|
||||
# (:Specification)-[:HAS_PAPER]->(:ExamPaper)
|
||||
# (:ExamPaper)-[:HAS_QUESTION]->(:Question)
|
||||
# (:Question)-[:HAS_PART]->(:Part) # nested questions allowed
|
||||
# (:Part)-[:HAS_REGION]->(:Region)
|
||||
# (:Part)-[:ASSESSES]->(:SpecPoint) # from exam_questions.spec_ref
|
||||
# (:SpecPoint)-[:TEACHES]->(:LearningStatement) # DEFERRED cross-db bridge
|
||||
@@ -1,5 +1,13 @@
|
||||
from typing import ClassVar
|
||||
from .base_nodes import UserBaseNode
|
||||
from .base_nodes import UserBaseNode, CCBaseNode
|
||||
|
||||
class UserNode(UserBaseNode):
|
||||
__primarylabel__: ClassVar[str] = 'User'
|
||||
|
||||
class JournalNode(CCBaseNode):
|
||||
__primarylabel__: ClassVar[str] = 'Journal'
|
||||
user_id: str
|
||||
|
||||
class PlannerNode(CCBaseNode):
|
||||
__primarylabel__: ClassVar[str] = 'Planner'
|
||||
user_id: str
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from modules.logger_tool import initialise_logger
|
||||
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
|
||||
import modules.database.tools.neo4j_driver_tools as driver_tools
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
|
||||
|
||||
MembershipRow = Dict[str, Any]
|
||||
GraphProbe = Callable[..., Dict[str, Any]]
|
||||
|
||||
|
||||
ROLE_PERMISSIONS: Dict[str, Dict[str, bool]] = {
|
||||
"school_admin": {
|
||||
"can_manage_school": True,
|
||||
"can_manage_calendar": True,
|
||||
"can_manage_timetable": True,
|
||||
"can_invite_staff": True,
|
||||
"can_manage_classes": True,
|
||||
"can_view_student_data": True,
|
||||
},
|
||||
"department_head": {
|
||||
"can_manage_school": False,
|
||||
"can_manage_calendar": False,
|
||||
"can_manage_timetable": True,
|
||||
"can_invite_staff": False,
|
||||
"can_manage_classes": True,
|
||||
"can_view_student_data": True,
|
||||
},
|
||||
"teacher": {
|
||||
"can_manage_school": False,
|
||||
"can_manage_calendar": False,
|
||||
"can_manage_timetable": False,
|
||||
"can_invite_staff": False,
|
||||
"can_manage_classes": True,
|
||||
"can_view_student_data": False,
|
||||
},
|
||||
"student": {
|
||||
"can_manage_school": False,
|
||||
"can_manage_calendar": False,
|
||||
"can_manage_timetable": False,
|
||||
"can_invite_staff": False,
|
||||
"can_manage_classes": False,
|
||||
"can_view_student_data": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
BASE_PERMISSIONS: Dict[str, bool] = {
|
||||
"platform_admin": False,
|
||||
"platform_super_admin": False,
|
||||
"can_create_school": True,
|
||||
"can_manage_school": False,
|
||||
"can_manage_calendar": False,
|
||||
"can_manage_timetable": False,
|
||||
"can_invite_staff": False,
|
||||
"can_manage_classes": False,
|
||||
"can_view_student_data": False,
|
||||
"can_use_canvas": True,
|
||||
}
|
||||
|
||||
|
||||
def utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _safe_list(result: Any) -> List[Dict[str, Any]]:
|
||||
data = getattr(result, "data", None)
|
||||
if not data:
|
||||
return []
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
return [data]
|
||||
|
||||
|
||||
def _safe_one(result: Any) -> Optional[Dict[str, Any]]:
|
||||
data = getattr(result, "data", None)
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def default_graph_probe(user_id: str, user_email: str, active_institute: Optional[Dict[str, Any]], institute: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Best-effort derived graph status; never authoritative for bootstrap state."""
|
||||
user_db = f"cc.users.teacher.{user_id.replace('-', '')}" if user_id else None
|
||||
neo4j_uuid = (institute or {}).get("neo4j_uuid_string")
|
||||
institute_db = f"cc.institutes.{neo4j_uuid}" if neo4j_uuid else None
|
||||
status = {
|
||||
"available": False,
|
||||
"user_db": user_db,
|
||||
"institute_db": institute_db,
|
||||
"projection_state": "unknown",
|
||||
"needs_rebuild": True,
|
||||
"last_checked_at": utc_now_iso(),
|
||||
"error_code": None,
|
||||
}
|
||||
try:
|
||||
if not user_db and not institute_db:
|
||||
status["projection_state"] = "missing"
|
||||
return status
|
||||
db_to_check = institute_db or user_db
|
||||
with driver_tools.get_session(database=db_to_check) as session:
|
||||
session.run("RETURN 1 AS ok").single()
|
||||
status.update({"available": True, "projection_state": "ready", "needs_rebuild": False})
|
||||
return status
|
||||
except Exception:
|
||||
status.update({"available": False, "projection_state": "error", "needs_rebuild": True, "error_code": "neo4j_unavailable"})
|
||||
return status
|
||||
|
||||
|
||||
class BootstrapService:
|
||||
def __init__(self, supabase: Any, graph_probe: Optional[GraphProbe] = None):
|
||||
self.supabase = supabase
|
||||
self.graph_probe = graph_probe or default_graph_probe
|
||||
|
||||
def build(self, credentials: Dict[str, Any]) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub") or ""
|
||||
user_email = credentials.get("email") or ""
|
||||
if not user_id:
|
||||
raise ValueError("Missing authenticated user id")
|
||||
|
||||
profile = self._get_profile(user_id, user_email)
|
||||
admin_profile = self._get_admin_profile(user_id)
|
||||
memberships = self._get_memberships(user_id)
|
||||
active = self._select_active_institute(profile, memberships)
|
||||
active_institute = self._membership_institute(active, memberships)
|
||||
|
||||
permissions = self._permissions(active, bool(admin_profile), bool((admin_profile or {}).get("is_super_admin")))
|
||||
profile_user_type = "platform_admin" if admin_profile else (profile.get("user_type") or active.get("role") or "unknown")
|
||||
school_status = self._school_status(active, memberships, admin_profile)
|
||||
|
||||
calendar_status = self._calendar_status(active.get("institute_id"))
|
||||
timetable_status = self._timetable_status(user_id, active.get("institute_id"))
|
||||
graph_status = self._graph_status(user_id, user_email, active, active_institute)
|
||||
onboarding = self._onboarding(school_status, permissions, calendar_status, timetable_status, graph_status)
|
||||
|
||||
return {
|
||||
"profile": {
|
||||
"id": user_id,
|
||||
"email": profile.get("email") or user_email,
|
||||
"display_name": profile.get("display_name") or profile.get("full_name") or profile.get("username") or user_email,
|
||||
"user_type": profile_user_type,
|
||||
"school_id": profile.get("school_id"),
|
||||
},
|
||||
"memberships": [self._serialize_membership(row) for row in memberships],
|
||||
"active_institute": {
|
||||
"id": active.get("institute_id"),
|
||||
"source": active.get("source", "none"),
|
||||
"membership_role": active.get("role"),
|
||||
},
|
||||
"permissions": permissions,
|
||||
"school_status": school_status,
|
||||
"onboarding": onboarding,
|
||||
"calendar_status": calendar_status,
|
||||
"timetable_status": timetable_status,
|
||||
"graph_status": graph_status,
|
||||
}
|
||||
|
||||
def _get_profile(self, user_id: str, user_email: str) -> Dict[str, Any]:
|
||||
try:
|
||||
result = (
|
||||
self.supabase.table("profiles")
|
||||
.select("id,email,user_type,username,full_name,display_name,user_db_name,school_db_name,school_id")
|
||||
.eq("id", user_id)
|
||||
.single()
|
||||
.execute()
|
||||
)
|
||||
profile = _safe_one(result) or {}
|
||||
except Exception as exc:
|
||||
logger.warning("Bootstrap profile lookup failed for authenticated user: %s", exc)
|
||||
profile = {}
|
||||
profile.setdefault("id", user_id)
|
||||
profile.setdefault("email", user_email)
|
||||
return profile
|
||||
|
||||
def _get_admin_profile(self, user_id: str) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
result = (
|
||||
self.supabase.table("admin_profiles")
|
||||
.select("id,admin_role,is_super_admin")
|
||||
.eq("id", user_id)
|
||||
.single()
|
||||
.execute()
|
||||
)
|
||||
return _safe_one(result)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _get_memberships(self, user_id: str) -> List[MembershipRow]:
|
||||
try:
|
||||
member_rows = _safe_list(
|
||||
self.supabase.table("institute_memberships")
|
||||
.select("profile_id,institute_id,role")
|
||||
.eq("profile_id", user_id)
|
||||
.execute()
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Bootstrap membership lookup failed: %s", exc)
|
||||
return []
|
||||
|
||||
institute_ids = [str(row.get("institute_id")) for row in member_rows if row.get("institute_id")]
|
||||
institutes_by_id: Dict[str, Dict[str, Any]] = {}
|
||||
if institute_ids:
|
||||
try:
|
||||
inst_rows = _safe_list(
|
||||
self.supabase.table("institutes")
|
||||
.select("id,name,urn,website,address,metadata,status,neo4j_uuid_string")
|
||||
.in_("id", institute_ids)
|
||||
.execute()
|
||||
)
|
||||
institutes_by_id = {str(row.get("id")): row for row in inst_rows}
|
||||
except Exception as exc:
|
||||
logger.warning("Bootstrap institute lookup failed: %s", exc)
|
||||
|
||||
rows: List[MembershipRow] = []
|
||||
for member in member_rows:
|
||||
inst = institutes_by_id.get(str(member.get("institute_id")), {})
|
||||
if inst and inst.get("status") not in (None, "active"):
|
||||
continue
|
||||
rows.append({**member, "institute": inst, "status": member.get("status") or "active", "is_active": True})
|
||||
return rows
|
||||
|
||||
def _select_active_institute(self, profile: Dict[str, Any], memberships: List[MembershipRow]) -> MembershipRow:
|
||||
active_memberships = [m for m in memberships if m.get("is_active")]
|
||||
profile_school_id = profile.get("school_id")
|
||||
if profile_school_id:
|
||||
for member in active_memberships:
|
||||
if str(member.get("institute_id")) == str(profile_school_id):
|
||||
return {**member, "source": "profile.school_id"}
|
||||
if len(active_memberships) == 1:
|
||||
return {**active_memberships[0], "source": "single_membership"}
|
||||
return {"institute_id": None, "role": None, "source": "none"}
|
||||
|
||||
def _membership_institute(self, active: MembershipRow, memberships: List[MembershipRow]) -> Optional[Dict[str, Any]]:
|
||||
active_id = active.get("institute_id")
|
||||
if not active_id:
|
||||
return None
|
||||
for member in memberships:
|
||||
if str(member.get("institute_id")) == str(active_id):
|
||||
return member.get("institute") or None
|
||||
return None
|
||||
|
||||
def _serialize_membership(self, row: MembershipRow) -> Dict[str, Any]:
|
||||
inst = row.get("institute") or {}
|
||||
return {
|
||||
"institute_id": row.get("institute_id"),
|
||||
"role": row.get("role") or "teacher",
|
||||
"status": row.get("status") or "active",
|
||||
"is_active": bool(row.get("is_active", True)),
|
||||
"institute": {
|
||||
"id": inst.get("id") or row.get("institute_id"),
|
||||
"name": inst.get("name"),
|
||||
"urn": inst.get("urn"),
|
||||
"website": inst.get("website"),
|
||||
"address": inst.get("address") or {},
|
||||
"metadata": inst.get("metadata") or {},
|
||||
},
|
||||
}
|
||||
|
||||
def _permissions(self, active: MembershipRow, platform_admin: bool, super_admin: bool) -> Dict[str, bool]:
|
||||
permissions = dict(BASE_PERMISSIONS)
|
||||
permissions["platform_admin"] = platform_admin
|
||||
permissions["platform_super_admin"] = super_admin
|
||||
role_permissions = ROLE_PERMISSIONS.get(active.get("role") or "", {})
|
||||
permissions.update(role_permissions)
|
||||
if platform_admin:
|
||||
# Platform authority is additive and must not be reduced by a user's
|
||||
# school membership role (for example a platform admin who also has
|
||||
# a teacher/student membership).
|
||||
permissions.update({
|
||||
"can_create_school": True,
|
||||
"can_manage_school": True,
|
||||
"can_manage_calendar": True,
|
||||
"can_manage_timetable": True,
|
||||
"can_invite_staff": True,
|
||||
"can_manage_classes": True,
|
||||
"can_view_student_data": True,
|
||||
})
|
||||
return permissions
|
||||
|
||||
def _school_status(self, active: MembershipRow, memberships: List[MembershipRow], admin_profile: Optional[Dict[str, Any]]) -> str:
|
||||
if admin_profile:
|
||||
return "platform_admin"
|
||||
if active.get("institute_id"):
|
||||
return "school_admin" if active.get("role") == "school_admin" else "member"
|
||||
if len([m for m in memberships if m.get("is_active")]) > 1:
|
||||
return "multi_school_needs_selection"
|
||||
return "no_school"
|
||||
|
||||
def _calendar_status(self, institute_id: Optional[str]) -> Dict[str, Any]:
|
||||
status = {"available": False, "academic_year_count": 0, "term_count": 0, "current_academic_year_id": None, "needs_setup": True}
|
||||
if not institute_id:
|
||||
return status
|
||||
try:
|
||||
years = _safe_list(self.supabase.table("academic_years").select("id,institute_id").eq("institute_id", institute_id).execute())
|
||||
terms = _safe_list(self.supabase.table("academic_terms").select("id,institute_id").eq("institute_id", institute_id).execute())
|
||||
status["academic_year_count"] = len(years)
|
||||
status["term_count"] = len(terms)
|
||||
status["current_academic_year_id"] = years[0].get("id") if years else None
|
||||
status["available"] = bool(years and terms)
|
||||
status["needs_setup"] = not status["available"]
|
||||
except Exception as exc:
|
||||
logger.warning("Bootstrap calendar status lookup failed: %s", exc)
|
||||
return status
|
||||
|
||||
def _timetable_status(self, user_id: str, institute_id: Optional[str]) -> Dict[str, Any]:
|
||||
status = {"available": False, "teacher_timetable_id": None, "slot_count": 0, "needs_setup": True}
|
||||
if not institute_id:
|
||||
return status
|
||||
try:
|
||||
timetables = _safe_list(
|
||||
self.supabase.table("teacher_timetables")
|
||||
.select("id,profile_id,institute_id")
|
||||
.eq("institute_id", institute_id)
|
||||
.eq("profile_id", user_id)
|
||||
.limit(1)
|
||||
.execute()
|
||||
)
|
||||
if not timetables:
|
||||
# Test/future compatibility for teacher_profile_id naming.
|
||||
timetables = _safe_list(
|
||||
self.supabase.table("teacher_timetables")
|
||||
.select("id,teacher_profile_id,institute_id")
|
||||
.eq("institute_id", institute_id)
|
||||
.eq("teacher_profile_id", user_id)
|
||||
.limit(1)
|
||||
.execute()
|
||||
)
|
||||
if timetables:
|
||||
timetable_id = timetables[0].get("id")
|
||||
slots = _safe_list(
|
||||
self.supabase.table("teacher_timetable_slots")
|
||||
.select("id,teacher_timetable_id")
|
||||
.eq("teacher_timetable_id", timetable_id)
|
||||
.execute()
|
||||
)
|
||||
status["teacher_timetable_id"] = timetable_id
|
||||
status["slot_count"] = len(slots)
|
||||
status["available"] = bool(slots)
|
||||
status["needs_setup"] = not status["available"]
|
||||
except Exception as exc:
|
||||
logger.warning("Bootstrap timetable status lookup failed: %s", exc)
|
||||
return status
|
||||
|
||||
def _graph_status(self, user_id: str, user_email: str, active: MembershipRow, institute: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
base = {"available": False, "user_db": None, "institute_db": None, "projection_state": "unknown", "needs_rebuild": True, "last_checked_at": utc_now_iso(), "error_code": None}
|
||||
try:
|
||||
result = self.graph_probe(user_id=user_id, user_email=user_email, active_institute=active, institute=institute)
|
||||
return {**base, **(result or {}), "last_checked_at": (result or {}).get("last_checked_at") or base["last_checked_at"]}
|
||||
except Exception:
|
||||
return {**base, "projection_state": "error", "needs_rebuild": True, "error_code": "neo4j_unavailable"}
|
||||
|
||||
def _onboarding(self, school_status: str, permissions: Dict[str, bool], calendar: Dict[str, Any], timetable: Dict[str, Any], graph: Dict[str, Any]) -> Dict[str, Any]:
|
||||
optional = [] if graph.get("available") else ["graph_rebuild"]
|
||||
if school_status == "no_school":
|
||||
return {"next_step": "create_or_join_school", "required": ["school_membership"], "optional": optional, "message": "Create or join a school to finish setup."}
|
||||
if school_status == "multi_school_needs_selection":
|
||||
return {"next_step": "select_school", "required": ["active_school_selection"], "optional": optional, "message": "Select which school to use for this session."}
|
||||
if permissions.get("can_manage_calendar") and calendar.get("needs_setup"):
|
||||
return {"next_step": "setup_calendar", "required": ["calendar"], "optional": optional, "message": "Set up the school calendar."}
|
||||
if permissions.get("can_manage_timetable") and timetable.get("needs_setup"):
|
||||
return {"next_step": "setup_timetable", "required": ["timetable"], "optional": optional, "message": "Set up the timetable."}
|
||||
if school_status == "school_admin" and permissions.get("can_invite_staff"):
|
||||
return {"next_step": "invite_staff", "required": [], "optional": optional, "message": "Invite staff or continue to your workspace."}
|
||||
return {"next_step": "ready", "required": [], "optional": optional, "message": "Your workspace is ready."}
|
||||
|
||||
|
||||
def build_bootstrap_response(credentials: Dict[str, Any]) -> Dict[str, Any]:
|
||||
sb = SupabaseServiceRoleClient()
|
||||
return BootstrapService(sb.supabase).build(credentials)
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Project a saved exam template into the cc.public.exams Neo4j graph (card S4-7).
|
||||
|
||||
Supabase is source of truth for the operational template (geometry, marks); cc.public.exams is
|
||||
the co-primary knowledge graph (spec §2/S2). On template save the structural skeleton —
|
||||
ExamPaper → Question/Part → Region, plus Part-[:ASSESSES]->SpecPoint — is projected here.
|
||||
|
||||
Ownership model (R3.5.1): the graph is written by the API SERVICE ROLE only (no client writes),
|
||||
so this task reads the template via service role and writes Neo4j with the system driver. It is
|
||||
the sanctioned service-role path (documented in the ADR), distinct from the as-user request path.
|
||||
|
||||
Join keys (never regenerated):
|
||||
exam_questions.id -> Question.uuid_string (container) | Part.uuid_string (leaf)
|
||||
exam_response_areas.id -> Region.uuid_string
|
||||
eb_exams.exam_code -> ExamPaper.exam_code
|
||||
eb_specifications.spec_code -> Specification.spec_code
|
||||
|
||||
Projection is a full re-sync per exam_code (delete this paper's Question/Part/Region, recreate),
|
||||
matching the PUT full-replace semantics — idempotent and safe to re-run.
|
||||
"""
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
|
||||
from modules.database.tools.neo4j_driver_tools import get_session
|
||||
from modules.logger_tool import initialise_logger
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
|
||||
|
||||
# MUST match run/initialization/init_exam_graph.py (shared DB name + deterministic uuid namespace).
|
||||
EXAM_DB = "cc.public.exams"
|
||||
NS = uuid.UUID("00000000-0000-0000-0000-00000000e8a1")
|
||||
|
||||
|
||||
def _uid(*parts: str) -> str:
|
||||
return str(uuid.uuid5(NS, ":".join(parts)))
|
||||
|
||||
|
||||
def _rows(result: Any) -> List[Dict[str, Any]]:
|
||||
data = getattr(result, "data", None)
|
||||
if not data:
|
||||
return []
|
||||
return data if isinstance(data, list) else [data]
|
||||
|
||||
|
||||
def project_template(template_id: str) -> Dict[str, Any]:
|
||||
"""Read the template (service role) and (re)project its structure into cc.public.exams.
|
||||
|
||||
Returns a counts dict. Raises on hard failure (caller decides whether to swallow — a
|
||||
BackgroundTask logs and drops; the manual /neo4j-sync endpoint surfaces the error).
|
||||
"""
|
||||
sb = SupabaseServiceRoleClient().supabase
|
||||
template = (sb.table("exam_templates").select("*").eq("id", template_id).limit(1).execute().data or [None])[0]
|
||||
if not template:
|
||||
raise ValueError(f"template {template_id} not found")
|
||||
|
||||
questions = _rows(sb.table("exam_questions").select("*").eq("template_id", template_id).order("order").execute())
|
||||
regions = _rows(sb.table("exam_response_areas").select("*").eq("template_id", template_id).execute())
|
||||
|
||||
# Resolve the paper's exam_code + spec metadata. Catalogue paper → from eb_exams; ad-hoc upload
|
||||
# (no exam_code) → a stable synthetic code so the paper still has a unique graph key.
|
||||
exam_code = template.get("exam_code")
|
||||
spec_code = None
|
||||
paper_meta: Dict[str, Any] = {}
|
||||
if template.get("exam_id"):
|
||||
eb = (sb.table("eb_exams").select("exam_code, spec_code, paper_code, tier, session")
|
||||
.eq("id", template["exam_id"]).limit(1).execute().data or [None])[0]
|
||||
if eb:
|
||||
exam_code = exam_code or eb.get("exam_code")
|
||||
spec_code = eb.get("spec_code")
|
||||
paper_meta = eb
|
||||
if not exam_code:
|
||||
exam_code = f"tpl:{template_id}"
|
||||
|
||||
paper_uid = _uid("ExamPaper", exam_code)
|
||||
counts = {"exam_code": exam_code, "questions": 0, "parts": 0, "regions": 0, "assesses": 0, "spec_linked": False}
|
||||
|
||||
with get_session(database=EXAM_DB) as s:
|
||||
# 1. Clean this paper's existing children (full re-sync), keep the ExamPaper node itself.
|
||||
s.run("MATCH (r:Region {exam_code:$ec}) DETACH DELETE r", ec=exam_code).consume()
|
||||
s.run("MATCH (n {exam_code:$ec}) WHERE n:Question OR n:Part DETACH DELETE n", ec=exam_code).consume()
|
||||
|
||||
# 2. ExamPaper node.
|
||||
s.run(
|
||||
"MERGE (p:ExamPaper {uuid_string:$uid}) "
|
||||
"SET p.exam_code=$ec, p.spec_code=$sc, p.title=$title, p.page_count=$pc, "
|
||||
" p.paper_code=$paper_code, p.tier=$tier, p.session=$session, p.node_storage_path=$nsp",
|
||||
uid=paper_uid, ec=exam_code, sc=spec_code, title=template.get("title"),
|
||||
pc=template.get("page_count"), paper_code=paper_meta.get("paper_code"),
|
||||
tier=paper_meta.get("tier"), session=paper_meta.get("session"),
|
||||
nsp=f"{EXAM_DB}/ExamPaper/{exam_code}",
|
||||
).consume()
|
||||
|
||||
# 3. Link to its Specification (seeded separately) when known.
|
||||
if spec_code:
|
||||
r = s.run(
|
||||
"MATCH (sp:Specification {spec_code:$sc}), (p:ExamPaper {exam_code:$ec}) "
|
||||
"MERGE (sp)-[:HAS_PAPER]->(p) RETURN count(*) AS n",
|
||||
sc=spec_code, ec=exam_code,
|
||||
).single()
|
||||
counts["spec_linked"] = bool(r and r["n"])
|
||||
|
||||
# 4. Question/Part nodes — pass 1: create all nodes (so parents exist before linking).
|
||||
for q in questions:
|
||||
label = "Question" if q.get("is_container") else "Part"
|
||||
s.run(
|
||||
f"MERGE (n:{label} {{uuid_string:$uid}}) "
|
||||
"SET n.exam_code=$ec, n.label=$label, n.order=$order, n.max_marks=$mm, "
|
||||
" n.answer_type=$at, n.mark_scheme_type=$mst, n.spec_ref=$sref, "
|
||||
" n.node_storage_path=$nsp",
|
||||
uid=q["id"], ec=exam_code, label=q.get("label"), order=q.get("order") or 0,
|
||||
mm=q.get("max_marks") or 0, at=q.get("answer_type"),
|
||||
mst=(q.get("mark_scheme") or {}).get("type") if isinstance(q.get("mark_scheme"), dict) else None,
|
||||
sref=q.get("spec_ref"), nsp=f"{EXAM_DB}/{label}/{q['id']}",
|
||||
).consume()
|
||||
counts["parts" if label == "Part" else "questions"] += 1
|
||||
|
||||
# 5. Structural + ASSESSES edges — pass 2.
|
||||
for q in questions:
|
||||
if q.get("parent_id"):
|
||||
s.run(
|
||||
"MATCH (parent {uuid_string:$pid}), (n {uuid_string:$uid}) MERGE (parent)-[:HAS_PART]->(n)",
|
||||
pid=q["parent_id"], uid=q["id"],
|
||||
).consume()
|
||||
else:
|
||||
s.run(
|
||||
"MATCH (p:ExamPaper {exam_code:$ec}), (n {uuid_string:$uid}) MERGE (p)-[:HAS_QUESTION]->(n)",
|
||||
ec=exam_code, uid=q["id"],
|
||||
).consume()
|
||||
if q.get("spec_ref"):
|
||||
# SpecPoints are seeded per spec_code; match within this paper's spec when known.
|
||||
r = s.run(
|
||||
"MATCH (n {uuid_string:$uid}), (sp:SpecPoint {ref:$ref}) "
|
||||
+ ("WHERE sp.spec_code=$sc " if spec_code else "")
|
||||
+ "MERGE (n)-[:ASSESSES]->(sp) RETURN count(*) AS n",
|
||||
uid=q["id"], ref=q["spec_ref"], sc=spec_code,
|
||||
).single()
|
||||
counts["assesses"] += (r["n"] if r else 0)
|
||||
|
||||
# 6. Region nodes + HAS_REGION edges.
|
||||
for rg in regions:
|
||||
s.run(
|
||||
"MERGE (r:Region {uuid_string:$uid}) "
|
||||
"SET r.exam_code=$ec, r.page=$page, r.kind=$kind, r.response_form=$rf, r.node_storage_path=$nsp",
|
||||
uid=rg["id"], ec=exam_code, page=rg.get("page"), kind=rg.get("kind"),
|
||||
rf=rg.get("response_form"), nsp=f"{EXAM_DB}/Region/{rg['id']}",
|
||||
).consume()
|
||||
s.run(
|
||||
"MATCH (q {uuid_string:$qid}), (r:Region {uuid_string:$uid}) MERGE (q)-[:HAS_REGION]->(r)",
|
||||
qid=rg["question_id"], uid=rg["id"],
|
||||
).consume()
|
||||
counts["regions"] += 1
|
||||
|
||||
logger.info(f"Projected template {template_id} → cc.public.exams: {counts}")
|
||||
return counts
|
||||
|
||||
|
||||
def project_template_safe(template_id: str) -> None:
|
||||
"""BackgroundTask wrapper: never raises (a failed projection must not break the HTTP save)."""
|
||||
try:
|
||||
project_template(template_id)
|
||||
except Exception as exc:
|
||||
logger.error(f"Background Neo4j projection failed for template {template_id}: {exc}")
|
||||
@@ -229,6 +229,7 @@ class ProvisioningService:
|
||||
"neo4j_private_db_name": school_db,
|
||||
"neo4j_private_sync_status": "ready",
|
||||
"neo4j_private_sync_at": datetime.utcnow().isoformat(),
|
||||
"neo4j_uuid_string": self._sanitize_component(institute_id),
|
||||
}
|
||||
try:
|
||||
(
|
||||
@@ -262,6 +263,7 @@ class ProvisioningService:
|
||||
"admin": ("superadmin", "superadmin"),
|
||||
"super_admin": ("superadmin", "superadmin"),
|
||||
"superadmin": ("superadmin", "superadmin"),
|
||||
"platform_admin": ("superadmin", "superadmin"),
|
||||
}
|
||||
neo_user_type, worker_type = user_type_map.get(user_type_raw, (user_type_raw or "standard", user_type_raw or "standard"))
|
||||
|
||||
|
||||
@@ -14,19 +14,35 @@ class CreateBucketOptions(TypedDict, total=False):
|
||||
allowed_mime_types: List[str]
|
||||
name: str
|
||||
|
||||
def _create_base_client(url: str, key: str, options: Optional[Dict[str, Any]] = None) -> Client:
|
||||
"""Create a base Supabase client with given configuration."""
|
||||
def _create_base_client(url: str, key: str, access_token: Optional[str] = None, options: Optional[Dict[str, Any]] = None) -> Client:
|
||||
"""Create a base Supabase client with given configuration.
|
||||
|
||||
If access_token is provided, it is used as the Authorization header (for per-user RLS).
|
||||
Otherwise, the API key is used (service role bypasses RLS, anon key does not).
|
||||
"""
|
||||
# If an access token is provided, use it for Authorization (enables per-user RLS)
|
||||
# Otherwise fall back to the API key
|
||||
auth_header = f"Bearer {access_token}" if access_token else f"Bearer {key}"
|
||||
|
||||
headers = {
|
||||
"Authorization": auth_header,
|
||||
}
|
||||
if options:
|
||||
headers.update(options.get("headers", {}))
|
||||
|
||||
client_options = SyncClientOptions(
|
||||
schema="public",
|
||||
storage=SyncMemoryStorage(),
|
||||
headers={
|
||||
"Authorization": f"Bearer {key}"
|
||||
}
|
||||
headers=headers,
|
||||
)
|
||||
return create_client(url, key, options=client_options)
|
||||
|
||||
class SupabaseServiceRoleClient:
|
||||
"""Supabase client for making authenticated requests using the service role key"""
|
||||
"""Supabase client for making authenticated requests using the service role key.
|
||||
|
||||
NOTE: Service role bypasses all RLS policies. Use only for admin operations
|
||||
where you need to read/write any row regardless of ownership.
|
||||
"""
|
||||
|
||||
def __init__(self, url: Optional[str] = None, service_role_key: Optional[str] = None):
|
||||
"""Initialize the Supabase client with URL and service role key"""
|
||||
@@ -36,7 +52,7 @@ class SupabaseServiceRoleClient:
|
||||
if not self.url or not self.service_role_key:
|
||||
raise ValueError("SUPABASE_URL and SERVICE_ROLE_KEY must be provided")
|
||||
|
||||
# Initialize Supabase client with service role key and optional access token
|
||||
# Initialize Supabase client with service role key (bypasses RLS)
|
||||
self.supabase = _create_base_client(self.url, self.service_role_key)
|
||||
|
||||
def create_bucket(self, id: str, options: Optional[CreateBucketOptions] = None) -> Dict[str, Any]:
|
||||
@@ -48,17 +64,29 @@ class SupabaseServiceRoleClient:
|
||||
return self.supabase.storage.create_bucket(id, options=options)
|
||||
|
||||
class SupabaseAnonClient:
|
||||
"""Supabase client for making authenticated requests using the anon key"""
|
||||
"""Supabase client for making authenticated requests using the anon key.
|
||||
|
||||
When initialized with an access_token, per-user RLS policies are enforced
|
||||
via auth.uid() in the JWT. Without an access_token, requests use the anon key
|
||||
which does NOT enforce per-user RLS (only bucket-level storage rules apply).
|
||||
"""
|
||||
|
||||
def __init__(self, url: Optional[str] = None, anon_key: Optional[str] = None, access_token: Optional[str] = None):
|
||||
"""Initialize the Supabase client with URL and anon key"""
|
||||
"""Initialize the Supabase client with URL and anon key.
|
||||
|
||||
Args:
|
||||
url: Supabase URL
|
||||
anon_key: Anon API key (fallback if no access_token)
|
||||
access_token: User's JWT access token for per-user RLS enforcement
|
||||
"""
|
||||
self.url = url or os.environ.get("SUPABASE_URL", "http://localhost:8000")
|
||||
self.anon_key = anon_key or os.environ.get("ANON_KEY")
|
||||
self.access_token = access_token
|
||||
|
||||
if not self.url or not self.anon_key:
|
||||
raise ValueError("SUPABASE_URL and ANON_KEY must be provided")
|
||||
|
||||
# Initialize Supabase client with anon key and optional access token
|
||||
# Initialize Supabase client with anon key and optional access token for RLS
|
||||
self.supabase = _create_base_client(self.url, self.anon_key, access_token=access_token)
|
||||
|
||||
def create_bucket(self, id: str, options: Optional[CreateBucketOptions] = None) -> Dict[str, Any]:
|
||||
@@ -67,5 +95,13 @@ class SupabaseAnonClient:
|
||||
|
||||
@classmethod
|
||||
def for_user(cls, access_token: str) -> 'SupabaseAnonClient':
|
||||
"""Create a client instance for a specific user using their access token"""
|
||||
return cls(access_token=access_token)
|
||||
"""Create a client instance for a specific user using their access token.
|
||||
|
||||
This enables per-user RLS enforcement via auth.uid() in the JWT.
|
||||
"""
|
||||
if not access_token or not access_token.strip():
|
||||
raise ValueError("access_token is required for per-user Supabase clients")
|
||||
token = access_token.strip()
|
||||
if token.lower().startswith("bearer "):
|
||||
token = token.split(None, 1)[1]
|
||||
return cls(access_token=token)
|
||||
|
||||
@@ -110,14 +110,13 @@ class StorageAdmin(StorageManager):
|
||||
public: bool = False,
|
||||
file_size_limit: Optional[int] = None,
|
||||
allowed_mime_types: Optional[List[str]] = None,
|
||||
owner: Optional[str] = None, # Kept for backwards compatibility but not used
|
||||
owner_id: Optional[str] = None # Kept for backwards compatibility but not used
|
||||
owner: Optional[str] = None,
|
||||
owner_id: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new storage bucket with supported parameters."""
|
||||
try:
|
||||
self.logger.info(f"Creating bucket {id} with name {name}")
|
||||
|
||||
# Prepare bucket options with only supported parameters
|
||||
options: Optional[CreateBucketOptions] = {}
|
||||
if public:
|
||||
options["public"] = public
|
||||
@@ -126,7 +125,6 @@ class StorageAdmin(StorageManager):
|
||||
if allowed_mime_types is not None:
|
||||
options["allowed_mime_types"] = allowed_mime_types
|
||||
|
||||
# Create bucket with supported parameters only
|
||||
bucket = self.client.supabase.storage.create_bucket(
|
||||
str(id),
|
||||
options=options if options else None
|
||||
@@ -152,7 +150,7 @@ class StorageAdmin(StorageManager):
|
||||
"public": False,
|
||||
"owner": owner_id,
|
||||
"owner_id": "superadmin",
|
||||
"file_size_limit": 50 * 1024 * 1024, # 50MB
|
||||
"file_size_limit": 50 * 1024 * 1024,
|
||||
"allowed_mime_types": [
|
||||
'image/*', 'video/*', 'application/pdf',
|
||||
'application/msword', 'application/vnd.openxmlformats-officedocument.*',
|
||||
@@ -165,7 +163,7 @@ class StorageAdmin(StorageManager):
|
||||
"public": False,
|
||||
"owner": owner_id,
|
||||
"owner_id": "superadmin",
|
||||
"file_size_limit": 50 * 1024 * 1024, # 50MB
|
||||
"file_size_limit": 50 * 1024 * 1024,
|
||||
"allowed_mime_types": [
|
||||
'image/*', 'video/*', 'application/pdf',
|
||||
'application/msword', 'application/vnd.openxmlformats-officedocument.*',
|
||||
@@ -177,7 +175,7 @@ class StorageAdmin(StorageManager):
|
||||
results = []
|
||||
for bucket in core_buckets:
|
||||
try:
|
||||
bucket_name = bucket.pop("name") # Remove name from options
|
||||
bucket_name = bucket.pop("name")
|
||||
result = self.create_bucket(name=bucket_name, **bucket)
|
||||
results.append({
|
||||
"bucket": bucket["id"],
|
||||
@@ -210,7 +208,7 @@ class StorageAdmin(StorageManager):
|
||||
public=False,
|
||||
owner=user_id,
|
||||
owner_id=username,
|
||||
file_size_limit=50 * 1024 * 1024, # 50MB
|
||||
file_size_limit=50 * 1024 * 1024,
|
||||
allowed_mime_types=[
|
||||
'image/*', 'video/*', 'application/pdf',
|
||||
'application/msword', 'application/vnd.openxmlformats-officedocument.*',
|
||||
@@ -236,7 +234,7 @@ class StorageAdmin(StorageManager):
|
||||
"public": True,
|
||||
"owner": owner_id,
|
||||
"owner_id": school_id,
|
||||
"file_size_limit": 50 * 1024 * 1024, # 50MB
|
||||
"file_size_limit": 50 * 1024 * 1024,
|
||||
"allowed_mime_types": [
|
||||
'image/*', 'video/*', 'application/pdf',
|
||||
'application/msword', 'application/vnd.openxmlformats-officedocument.*',
|
||||
@@ -249,7 +247,7 @@ class StorageAdmin(StorageManager):
|
||||
"public": False,
|
||||
"owner": owner_id,
|
||||
"owner_id": school_id,
|
||||
"file_size_limit": 50 * 1024 * 1024, # 50MB
|
||||
"file_size_limit": 50 * 1024 * 1024,
|
||||
"allowed_mime_types": [
|
||||
'image/*', 'video/*', 'application/pdf',
|
||||
'application/msword', 'application/vnd.openxmlformats-officedocument.*',
|
||||
@@ -261,7 +259,7 @@ class StorageAdmin(StorageManager):
|
||||
results = {}
|
||||
for bucket in school_buckets:
|
||||
try:
|
||||
bucket_name = bucket.pop("name") # Remove name from options
|
||||
bucket_name = bucket.pop("name")
|
||||
result = self.create_bucket(name=bucket_name, **bucket)
|
||||
results[bucket["id"]] = {
|
||||
"status": "success",
|
||||
@@ -281,9 +279,20 @@ class StorageAdmin(StorageManager):
|
||||
raise StorageError(str(e))
|
||||
|
||||
class StorageUser(StorageManager):
|
||||
"""Storage user class for managing storage buckets with user role access."""
|
||||
"""Storage user class for managing storage with per-user RLS enforcement.
|
||||
|
||||
def __init__(self, user_id: Optional[str] = None):
|
||||
"""Initialize StorageUser with user role client."""
|
||||
super().__init__(SupabaseAnonClient())
|
||||
self.user_id = user_id
|
||||
Requires a user access token to enforce Row Level Security policies.
|
||||
Without a token, requests use the anon key which does NOT enforce per-user RLS.
|
||||
"""
|
||||
|
||||
def __init__(self, user_id: Optional[str] = None, access_token: Optional[str] = None):
|
||||
"""Initialize StorageUser with user role client.
|
||||
|
||||
Args:
|
||||
user_id: The user's ID (for logging/context)
|
||||
access_token: User's JWT access token for per-user RLS enforcement
|
||||
"""
|
||||
self.user_id = user_id
|
||||
# Pass access_token to enable per-user RLS via auth.uid() in JWT
|
||||
client = SupabaseAnonClient.for_user(access_token) if access_token else SupabaseAnonClient()
|
||||
super().__init__(client)
|
||||
|
||||
@@ -18,7 +18,7 @@ def _retry_with_backoff(
|
||||
) -> any:
|
||||
"""
|
||||
Helper function to retry operations with exponential backoff.
|
||||
|
||||
|
||||
Args:
|
||||
func: Function to retry
|
||||
max_attempts: Maximum number of retry attempts
|
||||
@@ -29,26 +29,26 @@ def _retry_with_backoff(
|
||||
attempt = 0
|
||||
delay = initial_delay
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
while attempt < max_attempts:
|
||||
try:
|
||||
return func()
|
||||
except Exception as e:
|
||||
attempt += 1
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
|
||||
# Check if we've exceeded the maximum total wait time
|
||||
if elapsed_time >= max_total_wait:
|
||||
logger.error(f"Exceeded maximum total wait time of {max_total_wait} seconds")
|
||||
raise
|
||||
|
||||
|
||||
if attempt == max_attempts:
|
||||
logger.error(f"Final attempt {attempt} failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
# Calculate next delay with exponential backoff, but cap it
|
||||
delay = min(delay * 2, max_delay)
|
||||
|
||||
|
||||
# If we're in a container initialization scenario, provide more context
|
||||
if "Connection refused" in str(e):
|
||||
logger.warning(
|
||||
@@ -59,7 +59,7 @@ def _retry_with_backoff(
|
||||
)
|
||||
else:
|
||||
logger.warning(f"Attempt {attempt} failed: {e}. Retrying in {delay:.1f} seconds...")
|
||||
|
||||
|
||||
time.sleep(delay)
|
||||
|
||||
def get_driver(db_name: Optional[str] = None, url: Optional[str] = None, auth: Optional[Tuple[str, str]] = None) -> Optional[Driver]:
|
||||
@@ -71,7 +71,7 @@ def get_driver(db_name: Optional[str] = None, url: Optional[str] = None, auth: O
|
||||
logger.error("Neo4j credentials not found in environment")
|
||||
return None
|
||||
auth = (username, password)
|
||||
|
||||
|
||||
if auth is None:
|
||||
logger.error("No authentication credentials provided")
|
||||
return None
|
||||
@@ -95,7 +95,7 @@ def get_driver(db_name: Optional[str] = None, url: Optional[str] = None, auth: O
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to establish Neo4j connection after all retries: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# Test the connection with the specific database
|
||||
if db_name and driver:
|
||||
def verify_database():
|
||||
@@ -127,27 +127,50 @@ def close_driver(driver: Optional[Driver]) -> None:
|
||||
logger.info("Closing driver")
|
||||
driver.close()
|
||||
|
||||
# Global driver instance
|
||||
# Global driver instance — None means not yet initialised, _driver_unavailable=True means connection failed
|
||||
_driver: Optional[Driver] = None
|
||||
_driver_unavailable: bool = False
|
||||
|
||||
def get_global_driver() -> Optional[Driver]:
|
||||
"""Get or create the global Neo4j driver instance."""
|
||||
global _driver
|
||||
"""Get or create the global Neo4j driver instance.
|
||||
|
||||
Caches both success and failure so a broken Neo4j connection causes
|
||||
a single 60-second retry at startup, then fast-fails on every
|
||||
subsequent call instead of hanging for 60s each time.
|
||||
"""
|
||||
global _driver, _driver_unavailable
|
||||
if _driver_unavailable:
|
||||
return None
|
||||
if _driver is None:
|
||||
_driver = get_driver()
|
||||
if _driver is None:
|
||||
_driver_unavailable = True
|
||||
logger.error("Neo4j driver unavailable — all subsequent Neo4j calls will fail fast until process restarts")
|
||||
return _driver
|
||||
|
||||
def reset_global_driver() -> None:
|
||||
"""Reset the cached driver, forcing a reconnection attempt on the next call.
|
||||
|
||||
Call this if Neo4j becomes available after the process started.
|
||||
"""
|
||||
global _driver, _driver_unavailable
|
||||
if _driver:
|
||||
close_driver(_driver)
|
||||
_driver = None
|
||||
_driver_unavailable = False
|
||||
logger.info("Global Neo4j driver reset — will reconnect on next request")
|
||||
|
||||
@contextmanager
|
||||
def get_session(database: Optional[str] = None) -> Generator[Session, None, None]:
|
||||
"""Get a Neo4j session using the global driver."""
|
||||
driver = get_global_driver()
|
||||
if driver is None:
|
||||
raise Exception("Failed to get Neo4j driver")
|
||||
|
||||
|
||||
session = None
|
||||
try:
|
||||
session = driver.session(database=database)
|
||||
yield session
|
||||
finally:
|
||||
if session:
|
||||
session.close()
|
||||
session.close()
|
||||
|
||||
@@ -491,7 +491,9 @@ class RedisManager:
|
||||
|
||||
try:
|
||||
if not self.client:
|
||||
raise Exception("No Redis connection")
|
||||
logger.info("Redis health check has no active client; connecting now")
|
||||
if not self.connect():
|
||||
raise Exception("No Redis connection")
|
||||
|
||||
# Test connection
|
||||
self.client.ping()
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
addopts = -q
|
||||
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
GET /database/timetable/timetables
|
||||
Optional filters: class_id, type, active
|
||||
Returns {"timetables": [...]} for the caller's school.
|
||||
"""
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
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
|
||||
|
||||
ADMIN_TYPES = ("school_admin", "department_head")
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class TimetableResponse(BaseModel):
|
||||
timetables: List[Dict[str, Any]]
|
||||
|
||||
|
||||
def _sb() -> SupabaseServiceRoleClient:
|
||||
return SupabaseServiceRoleClient()
|
||||
|
||||
|
||||
def _require_institute(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 "")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _is_admin(user_id: str, institute_id: str) -> bool:
|
||||
try:
|
||||
sb = _sb()
|
||||
r = (
|
||||
sb.supabase.table("institute_memberships")
|
||||
.select("role")
|
||||
.eq("profile_id", user_id)
|
||||
.eq("institute_id", institute_id)
|
||||
.in_("role", list(ADMIN_TYPES))
|
||||
.limit(1)
|
||||
.execute()
|
||||
)
|
||||
return bool(r.data)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@router.get("", response_model=TimetableResponse)
|
||||
async def list_timetables(
|
||||
class_id: Optional[str] = Query(None),
|
||||
type: Optional[str] = Query(None),
|
||||
active: Optional[bool] = Query(None),
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
|
||||
if not institute_id:
|
||||
return {"timetables": []}
|
||||
|
||||
sb = _sb()
|
||||
|
||||
if not _is_admin(user_id, institute_id):
|
||||
return {"timetables": []}
|
||||
|
||||
q = (
|
||||
sb.supabase.table("school_timetables")
|
||||
.select("*")
|
||||
.eq("institute_id", institute_id)
|
||||
)
|
||||
|
||||
if class_id:
|
||||
q = q.eq("class_id", class_id)
|
||||
if type:
|
||||
q = q.eq("type", type)
|
||||
if active is not None:
|
||||
q = q.eq("is_active", active)
|
||||
|
||||
res = q.order("created_at", desc=True).execute()
|
||||
return {"timetables": res.data or []}
|
||||
@@ -0,0 +1,626 @@
|
||||
"""
|
||||
Classes Router — Supabase-backed CRUD for the `classes` table.
|
||||
Institute members can read their institute's classes.
|
||||
School admins and teachers can create classes.
|
||||
School admins manage teacher/student assignments; teachers can leave/enroll.
|
||||
"""
|
||||
import os
|
||||
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()
|
||||
|
||||
|
||||
def _sb() -> SupabaseServiceRoleClient:
|
||||
return SupabaseServiceRoleClient()
|
||||
|
||||
|
||||
def _resolve_institute_id(user_id: str) -> Optional[str]:
|
||||
"""Return the Supabase institute UUID for this user via profiles.school_id."""
|
||||
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 "")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _require_institute(user_id: str) -> Optional[str]:
|
||||
"""Return institute_id, or None if the user has no school membership."""
|
||||
return _resolve_institute_id(user_id)
|
||||
|
||||
|
||||
def _is_school_admin(user_id: str, institute_id: str) -> bool:
|
||||
try:
|
||||
sb = _sb()
|
||||
r = (
|
||||
sb.supabase.table("institute_memberships")
|
||||
.select("role")
|
||||
.eq("profile_id", user_id)
|
||||
.eq("institute_id", institute_id)
|
||||
.in_("role", ["school_admin", "department_head"])
|
||||
.limit(1)
|
||||
.execute()
|
||||
)
|
||||
return bool(r.data)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ─── Request models ───────────────────────────────────────────────────────────
|
||||
|
||||
class CreateClassRequest(BaseModel):
|
||||
name: str
|
||||
class_code: Optional[str] = None
|
||||
subject: Optional[str] = None
|
||||
key_stage: Optional[str] = None
|
||||
year_group: Optional[str] = None
|
||||
academic_year: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class UpdateClassRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
class_code: Optional[str] = None
|
||||
subject: Optional[str] = None
|
||||
key_stage: Optional[str] = None
|
||||
year_group: Optional[str] = None
|
||||
academic_year: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class AddTeacherRequest(BaseModel):
|
||||
teacher_id: str
|
||||
is_primary: bool = False
|
||||
|
||||
|
||||
class AddStudentRequest(BaseModel):
|
||||
student_id: str
|
||||
|
||||
|
||||
# ─── Endpoints ────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("")
|
||||
async def list_classes(
|
||||
subject: Optional[str] = None,
|
||||
school_year: Optional[str] = None,
|
||||
academic_year: Optional[str] = None,
|
||||
key_stage: Optional[str] = None,
|
||||
year_group: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
active_only: bool = True,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
if not institute_id:
|
||||
return {"classes": [], "total": 0}
|
||||
sb = _sb()
|
||||
|
||||
q = sb.supabase.table("classes").select("*", count="exact").eq("institute_id", institute_id)
|
||||
if active_only:
|
||||
q = q.eq("is_active", True)
|
||||
if subject:
|
||||
q = q.eq("subject", subject)
|
||||
# support both param names
|
||||
yr = academic_year or school_year
|
||||
if yr:
|
||||
q = q.eq("academic_year", yr)
|
||||
if key_stage:
|
||||
q = q.eq("key_stage", key_stage)
|
||||
if year_group:
|
||||
q = q.eq("year_group", year_group)
|
||||
if search:
|
||||
q = q.ilike("name", f"%{search}%")
|
||||
|
||||
q = q.order("name").range(skip, skip + limit - 1)
|
||||
res = q.execute()
|
||||
|
||||
classes = res.data or []
|
||||
total = res.count or 0
|
||||
|
||||
# Attach student/teacher counts
|
||||
class_ids = [c["id"] for c in classes]
|
||||
teacher_counts: Dict[str, int] = {}
|
||||
student_counts: Dict[str, int] = {}
|
||||
if class_ids:
|
||||
try:
|
||||
tc = (
|
||||
sb.supabase.table("class_teachers")
|
||||
.select("class_id", count="exact")
|
||||
.in_("class_id", class_ids)
|
||||
.execute()
|
||||
)
|
||||
for row in (tc.data or []):
|
||||
teacher_counts[row["class_id"]] = teacher_counts.get(row["class_id"], 0) + 1
|
||||
|
||||
sc = (
|
||||
sb.supabase.table("class_students")
|
||||
.select("class_id")
|
||||
.in_("class_id", class_ids)
|
||||
.eq("status", "active")
|
||||
.execute()
|
||||
)
|
||||
for row in (sc.data or []):
|
||||
student_counts[row["class_id"]] = student_counts.get(row["class_id"], 0) + 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
enriched = [
|
||||
{**c, "teacher_count": teacher_counts.get(c["id"], 0), "student_count": student_counts.get(c["id"], 0)}
|
||||
for c in classes
|
||||
]
|
||||
return {"classes": enriched, "total": total}
|
||||
|
||||
|
||||
@router.get("/me/teacher")
|
||||
async def my_teaching_classes(
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
if not institute_id:
|
||||
return {"classes": []}
|
||||
sb = _sb()
|
||||
|
||||
assigned = (
|
||||
sb.supabase.table("class_teachers")
|
||||
.select("class_id, is_primary")
|
||||
.eq("teacher_id", user_id)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
if not assigned:
|
||||
return {"classes": []}
|
||||
|
||||
class_ids = [a["class_id"] for a in assigned]
|
||||
is_primary_map = {a["class_id"]: a["is_primary"] for a in assigned}
|
||||
|
||||
res = (
|
||||
sb.supabase.table("classes")
|
||||
.select("*")
|
||||
.in_("id", class_ids)
|
||||
.eq("institute_id", institute_id)
|
||||
.eq("is_active", True)
|
||||
.order("name")
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
enriched = [{**c, "is_primary_teacher": is_primary_map.get(c["id"], False)} for c in res]
|
||||
return {"classes": enriched}
|
||||
|
||||
|
||||
@router.get("/me/student")
|
||||
async def my_student_classes(
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
if not institute_id:
|
||||
return {"classes": []}
|
||||
sb = _sb()
|
||||
|
||||
enrolled = (
|
||||
sb.supabase.table("class_students")
|
||||
.select("class_id")
|
||||
.eq("student_id", user_id)
|
||||
.eq("status", "active")
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
if not enrolled:
|
||||
return {"classes": []}
|
||||
|
||||
class_ids = [e["class_id"] for e in enrolled]
|
||||
res = (
|
||||
sb.supabase.table("classes")
|
||||
.select("*")
|
||||
.in_("id", class_ids)
|
||||
.eq("institute_id", institute_id)
|
||||
.eq("is_active", True)
|
||||
.order("name")
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
return {"classes": res}
|
||||
|
||||
@router.get("/school/students")
|
||||
async def list_school_students(
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
"""List all students in the caller's school. Used by admin to add students to a class."""
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
if not institute_id:
|
||||
return {"students": []}
|
||||
sb = _sb()
|
||||
members = (
|
||||
sb.supabase.table("institute_memberships")
|
||||
.select("profile_id")
|
||||
.eq("institute_id", institute_id)
|
||||
.eq("role", "student")
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
student_ids = [m["profile_id"] for m in members]
|
||||
if not student_ids:
|
||||
return {"students": []}
|
||||
profiles = (
|
||||
sb.supabase.table("profiles")
|
||||
.select("id, full_name, display_name, email, user_type")
|
||||
.in_("id", student_ids)
|
||||
.order("full_name")
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
return {"students": profiles}
|
||||
|
||||
|
||||
|
||||
@router.get("/{class_id}")
|
||||
async def get_class(
|
||||
class_id: str,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
sb = _sb()
|
||||
|
||||
cls_res = (
|
||||
sb.supabase.table("classes")
|
||||
.select("*")
|
||||
.eq("id", class_id)
|
||||
.eq("institute_id", institute_id)
|
||||
.single()
|
||||
.execute()
|
||||
)
|
||||
if not cls_res.data:
|
||||
raise HTTPException(status_code=404, detail="Class not found")
|
||||
|
||||
teachers = (
|
||||
sb.supabase.table("class_teachers")
|
||||
.select("teacher_id, is_primary, can_edit, assigned_at")
|
||||
.eq("class_id", class_id)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
students = (
|
||||
sb.supabase.table("class_students")
|
||||
.select("student_id, status, enrolled_at")
|
||||
.eq("class_id", class_id)
|
||||
.eq("status", "active")
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
|
||||
# Enrich with profile data
|
||||
all_ids = [t["teacher_id"] for t in teachers] + [s["student_id"] for s in students]
|
||||
profile_map: Dict[str, Dict] = {}
|
||||
if all_ids:
|
||||
profiles = (
|
||||
sb.supabase.table("profiles")
|
||||
.select("id, full_name, display_name, email, user_type")
|
||||
.in_("id", list(set(all_ids)))
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
profile_map = {p["id"]: p for p in profiles}
|
||||
|
||||
for t in teachers:
|
||||
t["profile"] = profile_map.get(t["teacher_id"], {})
|
||||
for s in students:
|
||||
s["profile"] = profile_map.get(s["student_id"], {})
|
||||
|
||||
# Enrollment requests (pending)
|
||||
reqs = (
|
||||
sb.supabase.table("enrollment_requests")
|
||||
.select("id, student_id, status, created_at")
|
||||
.eq("class_id", class_id)
|
||||
.eq("status", "pending")
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
req_student_ids = [r["student_id"] for r in reqs if r.get("student_id")]
|
||||
req_profiles: Dict[str, Dict] = {}
|
||||
if req_student_ids:
|
||||
rp = (
|
||||
sb.supabase.table("profiles")
|
||||
.select("id, full_name, email")
|
||||
.in_("id", req_student_ids)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
req_profiles = {p["id"]: p for p in rp}
|
||||
for r in reqs:
|
||||
r["profile"] = req_profiles.get(r.get("student_id", ""), {})
|
||||
|
||||
return {
|
||||
**cls_res.data,
|
||||
"teachers": teachers,
|
||||
"students": students,
|
||||
"enrollment_requests": reqs,
|
||||
"student_count": len(students),
|
||||
}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_class(
|
||||
body: CreateClassRequest,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
sb = _sb()
|
||||
|
||||
row = {
|
||||
"institute_id": institute_id,
|
||||
"name": body.name,
|
||||
"created_by": user_id,
|
||||
}
|
||||
for field in ("class_code", "subject", "key_stage", "year_group", "academic_year", "description"):
|
||||
val = getattr(body, field)
|
||||
if val is not None:
|
||||
row[field] = val
|
||||
|
||||
res = sb.supabase.table("classes").insert(row).execute()
|
||||
new_class = (res.data or [{}])[0]
|
||||
class_id = new_class.get("id")
|
||||
|
||||
# Auto-assign creator as primary teacher if they have teacher role
|
||||
if class_id:
|
||||
try:
|
||||
mem = (
|
||||
sb.supabase.table("institute_memberships")
|
||||
.select("role")
|
||||
.eq("profile_id", user_id)
|
||||
.eq("institute_id", institute_id)
|
||||
.single()
|
||||
.execute()
|
||||
)
|
||||
if (mem.data or {}).get("role") in ("teacher", "department_head"):
|
||||
sb.supabase.table("class_teachers").insert({
|
||||
"class_id": class_id,
|
||||
"teacher_id": user_id,
|
||||
"is_primary": True,
|
||||
"assigned_by": user_id,
|
||||
}).execute()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(f"Class created: {class_id} '{body.name}' by {user_id}")
|
||||
return new_class
|
||||
|
||||
|
||||
@router.patch("/{class_id}")
|
||||
async def update_class(
|
||||
class_id: str,
|
||||
body: UpdateClassRequest,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
sb = _sb()
|
||||
|
||||
# Must be school admin OR primary teacher of this class
|
||||
is_admin = _is_school_admin(user_id, institute_id)
|
||||
if not is_admin:
|
||||
ct = (
|
||||
sb.supabase.table("class_teachers")
|
||||
.select("is_primary")
|
||||
.eq("class_id", class_id)
|
||||
.eq("teacher_id", user_id)
|
||||
.single()
|
||||
.execute()
|
||||
)
|
||||
if not (ct.data or {}).get("is_primary"):
|
||||
raise HTTPException(status_code=403, detail="Only school admins or the primary teacher can update this class")
|
||||
|
||||
updates = {k: v for k, v in body.dict().items() if v is not None}
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
updates["updated_at"] = datetime.utcnow().isoformat()
|
||||
|
||||
res = (
|
||||
sb.supabase.table("classes")
|
||||
.update(updates)
|
||||
.eq("id", class_id)
|
||||
.eq("institute_id", institute_id)
|
||||
.execute()
|
||||
)
|
||||
return (res.data or [{}])[0]
|
||||
|
||||
|
||||
@router.delete("/{class_id}")
|
||||
async def delete_class(
|
||||
class_id: str,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
|
||||
if not _is_school_admin(user_id, institute_id):
|
||||
raise HTTPException(status_code=403, detail="Only school admins can delete classes")
|
||||
|
||||
sb = _sb()
|
||||
sb.supabase.table("classes").update({"is_active": False}).eq("id", class_id).eq("institute_id", institute_id).execute()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/{class_id}/teachers")
|
||||
async def add_teacher(
|
||||
class_id: str,
|
||||
body: AddTeacherRequest,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
|
||||
if not _is_school_admin(user_id, institute_id):
|
||||
raise HTTPException(status_code=403, detail="Only school admins can assign teachers")
|
||||
|
||||
sb = _sb()
|
||||
res = sb.supabase.table("class_teachers").upsert({
|
||||
"class_id": class_id,
|
||||
"teacher_id": body.teacher_id,
|
||||
"is_primary": body.is_primary,
|
||||
"assigned_by": user_id,
|
||||
}, on_conflict="class_id,teacher_id").execute()
|
||||
return {"status": "ok", "row": (res.data or [{}])[0]}
|
||||
|
||||
|
||||
@router.delete("/{class_id}/teachers/{teacher_id}")
|
||||
async def remove_teacher(
|
||||
class_id: str,
|
||||
teacher_id: str,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
|
||||
if not _is_school_admin(user_id, institute_id):
|
||||
raise HTTPException(status_code=403, detail="Only school admins can remove teachers")
|
||||
|
||||
sb = _sb()
|
||||
sb.supabase.table("class_teachers").delete().eq("class_id", class_id).eq("teacher_id", teacher_id).execute()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
@router.post("/{class_id}/students")
|
||||
async def add_student(
|
||||
class_id: str,
|
||||
body: AddStudentRequest,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
|
||||
if not _is_school_admin(user_id, institute_id):
|
||||
raise HTTPException(status_code=403, detail="Only school admins can enroll students")
|
||||
|
||||
sb = _sb()
|
||||
res = sb.supabase.table("class_students").upsert({
|
||||
"class_id": class_id,
|
||||
"student_id": body.student_id,
|
||||
"status": "active",
|
||||
"enrolled_by": user_id,
|
||||
}, on_conflict="class_id,student_id").execute()
|
||||
return {"status": "ok", "row": (res.data or [{}])[0]}
|
||||
|
||||
|
||||
@router.delete("/{class_id}/students/{student_id}")
|
||||
async def remove_student(
|
||||
class_id: str,
|
||||
student_id: str,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
|
||||
if not _is_school_admin(user_id, institute_id):
|
||||
raise HTTPException(status_code=403, detail="Only school admins can remove students")
|
||||
|
||||
sb = _sb()
|
||||
sb.supabase.table("class_students").delete().eq("class_id", class_id).eq("student_id", student_id).execute()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/{class_id}/leave")
|
||||
async def leave_class(
|
||||
class_id: str,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
sb = _sb()
|
||||
sb.supabase.table("class_students").update({"status": "inactive"}).eq("class_id", class_id).eq("student_id", user_id).execute()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/{class_id}/enrollment-requests")
|
||||
async def list_enrollment_requests(
|
||||
class_id: str,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
|
||||
sb = _sb()
|
||||
reqs = (
|
||||
sb.supabase.table("enrollment_requests")
|
||||
.select("*")
|
||||
.eq("class_id", class_id)
|
||||
.eq("status", "pending")
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
return {"requests": reqs}
|
||||
|
||||
|
||||
@router.post("/{class_id}/enroll")
|
||||
async def request_enrollment(
|
||||
class_id: str,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
sb = _sb()
|
||||
res = sb.supabase.table("enrollment_requests").insert({
|
||||
"class_id": class_id,
|
||||
"student_id": user_id,
|
||||
"status": "pending",
|
||||
}).execute()
|
||||
return {"status": "ok", "request": (res.data or [{}])[0]}
|
||||
|
||||
|
||||
@router.patch("/{class_id}/enrollment-requests/{request_id}")
|
||||
async def respond_enrollment_request(
|
||||
class_id: str,
|
||||
request_id: str,
|
||||
body: dict,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
"""Approve or reject a pending enrollment request. School admin only."""
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
if not _is_school_admin(user_id, institute_id):
|
||||
raise HTTPException(status_code=403, detail="Only school admins can respond to enrollment requests")
|
||||
|
||||
action = body.get("action")
|
||||
if action not in ("approve", "reject"):
|
||||
raise HTTPException(status_code=400, detail="action must be 'approve' or 'reject'")
|
||||
|
||||
sb = _sb()
|
||||
req = (
|
||||
sb.supabase.table("enrollment_requests")
|
||||
.select("student_id, status")
|
||||
.eq("id", request_id)
|
||||
.eq("class_id", class_id)
|
||||
.single()
|
||||
.execute()
|
||||
)
|
||||
if not req.data:
|
||||
raise HTTPException(status_code=404, detail="Request not found")
|
||||
if req.data["status"] != "pending":
|
||||
raise HTTPException(status_code=400, detail="Request is not pending")
|
||||
|
||||
student_id = req.data["student_id"]
|
||||
new_status = "accepted" if action == "approve" else "rejected"
|
||||
sb.supabase.table("enrollment_requests").update({"status": new_status}).eq("id", request_id).execute()
|
||||
|
||||
if action == "approve":
|
||||
sb.supabase.table("class_students").upsert({
|
||||
"class_id": class_id,
|
||||
"student_id": student_id,
|
||||
"status": "active",
|
||||
"enrolled_by": user_id,
|
||||
}, on_conflict="class_id,student_id").execute()
|
||||
|
||||
return {"status": "ok", "action": action, "student_id": student_id}
|
||||
@@ -0,0 +1,902 @@
|
||||
import os
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from modules.logger_tool import initialise_logger
|
||||
from modules.auth.supabase_bearer import SupabaseBearer
|
||||
import modules.database.tools.neo4j_driver_tools as driver_tools
|
||||
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ─── DB helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _user_to_teacher_db(user_id: str) -> str:
|
||||
return f"cc.users.teacher.{user_id.replace('-', '')}"
|
||||
|
||||
|
||||
def _sb() -> SupabaseServiceRoleClient:
|
||||
return SupabaseServiceRoleClient()
|
||||
|
||||
|
||||
def _find_teacher_uuid(db: str, user_email: str) -> Optional[str]:
|
||||
"""Query teacher UUID from a known Neo4j institute DB."""
|
||||
try:
|
||||
with driver_tools.get_session(database=db) as session:
|
||||
rec = session.run(
|
||||
'MATCH (t:Teacher) WHERE t.worker_email = $email '
|
||||
'RETURN t.uuid_string AS uuid LIMIT 1',
|
||||
email=user_email,
|
||||
).single()
|
||||
if rec:
|
||||
return rec['uuid']
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_institute(
|
||||
user_id: str, user_email: str
|
||||
) -> tuple:
|
||||
"""Returns (supabase_institute_id, neo4j_institute_db, neo4j_teacher_uuid).
|
||||
Supabase-first lookup with Neo4j email-scan fallback."""
|
||||
try:
|
||||
sb = _sb()
|
||||
p = sb.supabase.table('profiles').select('school_id').eq('id', user_id).single().execute()
|
||||
school_id = (p.data or {}).get('school_id')
|
||||
if school_id:
|
||||
i = sb.supabase.table('institutes').select('id,neo4j_uuid_string').eq('id', str(school_id)).single().execute()
|
||||
inst = i.data or {}
|
||||
neo4j_uuid = inst.get('neo4j_uuid_string')
|
||||
if neo4j_uuid:
|
||||
db = f'cc.institutes.{neo4j_uuid}'
|
||||
teacher_uuid = _find_teacher_uuid(db, user_email)
|
||||
return str(school_id), db, teacher_uuid
|
||||
except Exception as e:
|
||||
logger.warning(f'Supabase-first institute resolve failed: {e}')
|
||||
# Fallback: scan Neo4j
|
||||
db, teacher_uuid = _find_teacher_institute(user_email)
|
||||
return None, db, teacher_uuid
|
||||
|
||||
|
||||
def _allowed_neo4j_dbs(user_id: str, user_email: str) -> set[str]:
|
||||
"""Return Neo4j databases this user may request via lazy graph APIs."""
|
||||
allowed = {f"cc.users.teacher.{user_id.replace('-', '')}"} if user_id else set()
|
||||
if user_id or user_email:
|
||||
_, institute_db, _ = _resolve_institute(user_id, user_email)
|
||||
if institute_db:
|
||||
allowed.add(institute_db)
|
||||
allowed.add(f"{institute_db}.curriculum")
|
||||
return allowed
|
||||
|
||||
|
||||
def _require_allowed_neo4j_db(neo4j_db_name: str, node_type: str, section_id: str, user_id: str, user_email: str) -> None:
|
||||
"""Reject arbitrary DB traversal from /graph/node/children query params."""
|
||||
if not neo4j_db_name:
|
||||
raise HTTPException(status_code=400, detail="neo4j_db_name is required")
|
||||
|
||||
if neo4j_db_name == "classroomcopilot":
|
||||
if node_type.startswith("Calendar") or section_id == "calendar":
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="Requested graph database is not allowed for this node")
|
||||
|
||||
if neo4j_db_name in _allowed_neo4j_dbs(user_id, user_email):
|
||||
return
|
||||
|
||||
raise HTTPException(status_code=403, detail="Requested graph database is outside the authenticated user's scope")
|
||||
|
||||
|
||||
def _find_teacher_institute(user_email: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Return (institute_db_name, teacher_uuid) by matching worker_email in all institute DBs."""
|
||||
if not user_email:
|
||||
return None, None
|
||||
try:
|
||||
with driver_tools.get_session(database="system") as session:
|
||||
result = session.run(
|
||||
"SHOW DATABASES YIELD name "
|
||||
"WHERE name STARTS WITH 'cc.institutes.' "
|
||||
"AND NOT name ENDS WITH '.curriculum' "
|
||||
"RETURN name"
|
||||
)
|
||||
dbs = [r["name"] for r in result]
|
||||
|
||||
for db in dbs:
|
||||
try:
|
||||
with driver_tools.get_session(database=db) as session:
|
||||
rec = session.run(
|
||||
"MATCH (t:Teacher) WHERE t.worker_email = $email "
|
||||
"RETURN t.uuid_string AS uuid LIMIT 1",
|
||||
email=user_email,
|
||||
).single()
|
||||
if rec and rec["uuid"]:
|
||||
return db, rec["uuid"]
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning(f"Institute lookup failed: {e}")
|
||||
return None, None
|
||||
|
||||
|
||||
# ─── Node queries ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _query_user_node(teacher_db: str) -> Optional[Dict]:
|
||||
try:
|
||||
with driver_tools.get_session(database=teacher_db) as session:
|
||||
rec = session.run("MATCH (u:User) RETURN u LIMIT 1").single()
|
||||
if not rec:
|
||||
return None
|
||||
u = rec["u"]
|
||||
return {
|
||||
"neo4j_node_id": u["uuid_string"],
|
||||
"label": u.get("user_name") or u.get("cc_username") or "My Workspace",
|
||||
"node_type": "User",
|
||||
"neo4j_db_name": teacher_db,
|
||||
"is_section": False,
|
||||
"has_children": True,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not query User node in {teacher_db}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _query_calendar_months(year: str) -> List[Dict]:
|
||||
try:
|
||||
with driver_tools.get_session(database="classroomcopilot") as session:
|
||||
result = session.run(
|
||||
"MATCH (y:CalendarYear {year: $year})-[:YEAR_INCLUDES_MONTH]->(m:CalendarMonth) "
|
||||
"RETURN m ORDER BY toInteger(m.month)",
|
||||
year=year,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"neo4j_node_id": r["m"]["uuid_string"],
|
||||
"label": r["m"]["month_name"],
|
||||
"node_type": "CalendarMonth",
|
||||
"neo4j_db_name": "classroomcopilot",
|
||||
"is_section": False,
|
||||
"has_children": True,
|
||||
}
|
||||
for r in result
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Error querying calendar months for {year}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _query_month_days(month_uuid: str) -> List[Dict]:
|
||||
try:
|
||||
with driver_tools.get_session(database="classroomcopilot") as session:
|
||||
result = session.run(
|
||||
"MATCH (m:CalendarMonth {uuid_string: $mid})-[:MONTH_INCLUDES_DAY]->(d:CalendarDay) "
|
||||
"RETURN d ORDER BY d.date",
|
||||
mid=month_uuid,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"neo4j_node_id": r["d"]["uuid_string"],
|
||||
"label": f"{r['d']['day_of_week'][:3]} {r['d']['iso_day']}",
|
||||
"node_type": "CalendarDay",
|
||||
"neo4j_db_name": "classroomcopilot",
|
||||
"is_section": False,
|
||||
"has_children": False,
|
||||
"neo4j_props": {
|
||||
"date": str(r["d"].get("date", "")),
|
||||
"day_of_week": r["d"].get("day_of_week", ""),
|
||||
"iso_day": r["d"].get("iso_day", ""),
|
||||
},
|
||||
}
|
||||
for r in result
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Error querying days for month {month_uuid}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _query_teacher_classes(
|
||||
user_id: str, institute_id: str, institute_db: str, section_id: str = ""
|
||||
) -> List[Dict]:
|
||||
"""Query classes for a teacher or student from Supabase (source of truth)."""
|
||||
try:
|
||||
sb = _sb()
|
||||
teacher_rows = (
|
||||
sb.supabase.table("class_teachers")
|
||||
.select("class_id, is_primary")
|
||||
.eq("teacher_id", user_id)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
teacher_class_ids = {r["class_id"] for r in teacher_rows}
|
||||
student_rows = (
|
||||
sb.supabase.table("class_students")
|
||||
.select("class_id")
|
||||
.eq("student_id", user_id)
|
||||
.eq("status", "active")
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
student_class_ids = {r["class_id"] for r in student_rows}
|
||||
all_ids = list(teacher_class_ids | student_class_ids)
|
||||
if not all_ids:
|
||||
return []
|
||||
classes = (
|
||||
sb.supabase.table("classes")
|
||||
.select("id, name, class_code, subject")
|
||||
.in_("id", all_ids)
|
||||
.eq("institute_id", institute_id)
|
||||
.eq("is_active", True)
|
||||
.order("name")
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
result = []
|
||||
for c in classes:
|
||||
role = "teacher" if c["id"] in teacher_class_ids else "student"
|
||||
label = c.get("class_code") or c.get("name") or "Class"
|
||||
node: Dict = {
|
||||
"neo4j_node_id": c["id"],
|
||||
"label": label,
|
||||
"node_type": "SubjectClass",
|
||||
"neo4j_db_name": institute_db,
|
||||
"is_section": False,
|
||||
"has_children": True,
|
||||
"neo4j_props": {
|
||||
"role": role,
|
||||
"subject": c.get("subject") or "",
|
||||
"name": c.get("name") or "",
|
||||
"class_code": c.get("class_code") or "",
|
||||
},
|
||||
}
|
||||
if section_id:
|
||||
node["section_id"] = section_id
|
||||
result.append(node)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not query classes for user {user_id}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# ─── Section builders ───────────────────────────────────────────────────────────
|
||||
|
||||
def _section(section_id: str, label: str, db: str, status: str,
|
||||
has_children: bool = False, children: Optional[List] = None) -> Dict:
|
||||
return {
|
||||
"neo4j_node_id": f"section_{section_id}",
|
||||
"label": label,
|
||||
"node_type": "Section",
|
||||
"section_id": section_id,
|
||||
"neo4j_db_name": db,
|
||||
"is_section": True,
|
||||
"has_children": has_children,
|
||||
"status": status,
|
||||
**({"children": children} if children is not None else {}),
|
||||
}
|
||||
|
||||
|
||||
def _build_calendar_section() -> Dict:
|
||||
try:
|
||||
with driver_tools.get_session(database="classroomcopilot") as session:
|
||||
rows = session.run(
|
||||
"MATCH (y:CalendarYear) RETURN y ORDER BY toInteger(y.year)"
|
||||
).data()
|
||||
if not rows:
|
||||
return _section("calendar", "Calendar", "classroomcopilot", "empty")
|
||||
year_nodes = [
|
||||
{
|
||||
"neo4j_node_id": r["y"]["uuid_string"],
|
||||
"label": r["y"].get("year") or r["y"]["uuid_string"],
|
||||
"node_type": "CalendarYear",
|
||||
"neo4j_db_name": "classroomcopilot",
|
||||
"is_section": False,
|
||||
"has_children": True,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return _section(
|
||||
"calendar", "Calendar", "classroomcopilot", "populated",
|
||||
has_children=True, children=year_nodes,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Calendar section build failed: {e}")
|
||||
return _section("calendar", "Calendar", "classroomcopilot", "empty")
|
||||
|
||||
|
||||
def _build_timetable_section(user_id: str, institute_id: Optional[str], institute_db: Optional[str], teacher_uuid: Optional[str]) -> Dict:
|
||||
if not institute_db or not teacher_uuid or not institute_id:
|
||||
return _section("timetable", "My Timetable", "", "no_school")
|
||||
|
||||
try:
|
||||
with driver_tools.get_session(database=institute_db) as session:
|
||||
rec = session.run(
|
||||
"MATCH (t:Teacher {uuid_string: $uuid})-[:HAS_TIMETABLE]->(tt) "
|
||||
"RETURN tt LIMIT 1",
|
||||
uuid=teacher_uuid,
|
||||
).single()
|
||||
if rec:
|
||||
tt = rec["tt"]
|
||||
tt_uuid = tt["uuid_string"]
|
||||
# Load classes from Supabase (source of truth)
|
||||
classes = _query_teacher_classes(user_id, institute_id, institute_db, section_id="timetable")
|
||||
return {
|
||||
**_section("timetable", "My Timetable", institute_db, "populated",
|
||||
has_children=True, children=classes if classes else None),
|
||||
"neo4j_node_id": tt_uuid,
|
||||
"node_type": "TeacherTimetable",
|
||||
"is_section": True,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Timetable query failed: {e}")
|
||||
|
||||
return _section("timetable", "My Timetable", institute_db, "empty")
|
||||
|
||||
|
||||
def _build_classes_section(user_id: str, institute_id: Optional[str], institute_db: Optional[str]) -> Dict:
|
||||
if not institute_db or not institute_id:
|
||||
return _section("classes", "My Classes", "", "no_school")
|
||||
|
||||
classes = _query_teacher_classes(user_id, institute_id, institute_db, section_id="classes")
|
||||
if classes:
|
||||
return _section("classes", "My Classes", institute_db, "populated",
|
||||
has_children=True, children=classes)
|
||||
return _section("classes", "My Classes", institute_db, "empty")
|
||||
|
||||
|
||||
def _build_curriculum_section(institute_db: Optional[str]) -> Dict:
|
||||
if not institute_db:
|
||||
return _section("curriculum", "Curriculum", "", "no_school")
|
||||
|
||||
# Check for curriculum DB
|
||||
curriculum_db = f"{institute_db}.curriculum"
|
||||
try:
|
||||
with driver_tools.get_session(database="system") as session:
|
||||
rec = session.run(
|
||||
"SHOW DATABASES YIELD name WHERE name = $db RETURN name",
|
||||
db=curriculum_db,
|
||||
).single()
|
||||
if not rec:
|
||||
return _section("curriculum", "Curriculum", institute_db, "empty")
|
||||
|
||||
with driver_tools.get_session(database=curriculum_db) as session:
|
||||
rec = session.run("MATCH (n) RETURN count(n) AS cnt").single()
|
||||
cnt = rec["cnt"] if rec else 0
|
||||
if cnt > 0:
|
||||
return _section("curriculum", "Curriculum", curriculum_db, "populated",
|
||||
has_children=True)
|
||||
except Exception as e:
|
||||
logger.warning(f"Curriculum check failed: {e}")
|
||||
|
||||
return _section("curriculum", "Curriculum", institute_db, "empty")
|
||||
|
||||
|
||||
def _build_journal_section(teacher_db: str) -> Dict:
|
||||
try:
|
||||
with driver_tools.get_session(database=teacher_db) as session:
|
||||
rec = session.run("MATCH (j:Journal) RETURN j LIMIT 1").single()
|
||||
if rec:
|
||||
j = rec["j"]
|
||||
return {
|
||||
**_section("journal", "Journal", teacher_db, "populated", has_children=True),
|
||||
"neo4j_node_id": j["uuid_string"],
|
||||
"node_type": "Journal",
|
||||
"is_section": True,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Journal query failed: {e}")
|
||||
|
||||
return _section("journal", "Journal", teacher_db, "not_initialized")
|
||||
|
||||
|
||||
def _build_planner_section(teacher_db: str) -> Dict:
|
||||
try:
|
||||
with driver_tools.get_session(database=teacher_db) as session:
|
||||
rec = session.run("MATCH (p:Planner) RETURN p LIMIT 1").single()
|
||||
if rec:
|
||||
p = rec["p"]
|
||||
return {
|
||||
**_section("planner", "Planner", teacher_db, "populated", has_children=True),
|
||||
"neo4j_node_id": p["uuid_string"],
|
||||
"node_type": "Planner",
|
||||
"is_section": True,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Planner query failed: {e}")
|
||||
|
||||
return _section("planner", "Planner", teacher_db, "not_initialized")
|
||||
|
||||
|
||||
def _build_school_section(institute_db: str) -> Dict:
|
||||
try:
|
||||
with driver_tools.get_session(database=institute_db) as session:
|
||||
rec = session.run("MATCH (s:School) RETURN s LIMIT 1").single()
|
||||
if not rec:
|
||||
return _section("school", "My School", institute_db, "empty")
|
||||
s = rec["s"]
|
||||
name = s.get("name") or "My School"
|
||||
# Academic years under this school
|
||||
ay_rows = session.run(
|
||||
"MATCH (ay:AcademicYear) RETURN ay ORDER BY ay.year"
|
||||
).data()
|
||||
children = [
|
||||
{
|
||||
"neo4j_node_id": row["ay"]["uuid_string"],
|
||||
"label": row["ay"].get("year") or "Academic Year",
|
||||
"node_type": "AcademicYear",
|
||||
"neo4j_db_name": institute_db,
|
||||
"is_section": False,
|
||||
"has_children": True,
|
||||
}
|
||||
for row in ay_rows
|
||||
]
|
||||
return {
|
||||
**_section("school", name, institute_db, "populated",
|
||||
has_children=len(children) > 0,
|
||||
children=children if children else None),
|
||||
"neo4j_node_id": s["uuid_string"],
|
||||
"node_type": "School",
|
||||
"is_section": True,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"School query failed: {e}")
|
||||
|
||||
return _section("school", "My School", institute_db, "empty")
|
||||
|
||||
|
||||
# ─── Lazy children for expanded nodes ──────────────────────────────────────────
|
||||
|
||||
def _get_children_for_node(
|
||||
neo4j_node_id: str,
|
||||
neo4j_db_name: str,
|
||||
node_type: str,
|
||||
section_id: str = "",
|
||||
user_email: str = "",
|
||||
) -> List[Dict]:
|
||||
# Calendar
|
||||
if node_type == "CalendarYear":
|
||||
return _query_calendar_months(neo4j_node_id)
|
||||
|
||||
if node_type == "CalendarMonth":
|
||||
return _query_month_days(neo4j_node_id)
|
||||
|
||||
# TeacherTimetable lazy-load (fallback if not pre-loaded, or for By-Term view)
|
||||
if node_type == "TeacherTimetable" and neo4j_db_name:
|
||||
if section_id in ("", "timetable"):
|
||||
# Classes are pre-loaded in _build_timetable_section via Supabase.
|
||||
# Lazy expansion here is not needed in normal flow.
|
||||
return []
|
||||
if section_id == "timetable-term":
|
||||
try:
|
||||
with driver_tools.get_session(database=neo4j_db_name) as session:
|
||||
result = session.run(
|
||||
"MATCH (tt:TeacherTimetable {uuid_string: $id}) "
|
||||
"-[:ACADEMIC_TIMETABLE_HAS_ACADEMIC_YEAR]->(ay:AcademicYear) "
|
||||
"-[:ACADEMIC_YEAR_HAS_ACADEMIC_TERM]->(t:AcademicTerm) "
|
||||
"RETURN t ORDER BY toInteger(t.term_number)",
|
||||
id=neo4j_node_id,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"neo4j_node_id": r["t"]["uuid_string"],
|
||||
"label": r["t"].get("term_name") or "Term {}".format(r["t"].get("term_number", "")),
|
||||
"node_type": "AcademicTerm",
|
||||
"neo4j_db_name": neo4j_db_name,
|
||||
"section_id": "timetable-term",
|
||||
"is_section": False,
|
||||
"has_children": True,
|
||||
}
|
||||
for r in result
|
||||
]
|
||||
except Exception as e:
|
||||
logger.warning(f"TeacherTimetable term children failed: {e}")
|
||||
return []
|
||||
|
||||
# SubjectClass — expand to taught lessons (timetable context) or members (classes context)
|
||||
if node_type == "SubjectClass":
|
||||
class_id = neo4j_node_id # Supabase class UUID
|
||||
try:
|
||||
sb = _sb()
|
||||
if section_id == "timetable" and user_email:
|
||||
# Resolve teacher profile_id from email
|
||||
prof = sb.supabase.table("profiles").select("id").eq("email", user_email).single().execute()
|
||||
teacher_id = (prof.data or {}).get("id")
|
||||
if teacher_id:
|
||||
lessons = (
|
||||
sb.supabase.table("taught_lessons")
|
||||
.select("id, date, period_code, class_name, subject")
|
||||
.eq("class_id", class_id)
|
||||
.eq("teacher_profile_id", teacher_id)
|
||||
.order("date")
|
||||
.order("period_code")
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
return [
|
||||
{
|
||||
"neo4j_node_id": tl["id"],
|
||||
"label": "{} {}".format(
|
||||
tl.get("period_code") or "",
|
||||
tl.get("date") or ""
|
||||
).strip(),
|
||||
"node_type": "TaughtLesson",
|
||||
"neo4j_db_name": neo4j_db_name,
|
||||
"is_section": False,
|
||||
"has_children": False,
|
||||
"section_id": "timetable",
|
||||
}
|
||||
for tl in lessons
|
||||
]
|
||||
return []
|
||||
if section_id == "classes":
|
||||
# Class members: students enrolled in this class
|
||||
members = (
|
||||
sb.supabase.table("class_students")
|
||||
.select("student_id, status, enrolled_at")
|
||||
.eq("class_id", class_id)
|
||||
.order("enrolled_at")
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
if not members:
|
||||
return []
|
||||
student_ids = [m["student_id"] for m in members]
|
||||
status_map = {m["student_id"]: m["status"] for m in members}
|
||||
profiles = (
|
||||
sb.supabase.table("profiles")
|
||||
.select("id, email, first_name, last_name, user_type")
|
||||
.in_("id", student_ids)
|
||||
.order("last_name")
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
return [
|
||||
{
|
||||
"neo4j_node_id": p["id"],
|
||||
"label": "{} {} ({})".format(
|
||||
p.get("first_name") or "",
|
||||
p.get("last_name") or "",
|
||||
status_map.get(p["id"], ""),
|
||||
).strip(),
|
||||
"node_type": "Student",
|
||||
"neo4j_db_name": neo4j_db_name,
|
||||
"is_section": False,
|
||||
"has_children": False,
|
||||
"section_id": "classes",
|
||||
}
|
||||
for p in profiles
|
||||
]
|
||||
except Exception as e:
|
||||
logger.warning(f"SubjectClass children failed: {e}")
|
||||
return []
|
||||
|
||||
# Section containers that need lazy loading
|
||||
if node_type == "Section":
|
||||
if section_id == "timetable" and neo4j_db_name:
|
||||
# Children of timetable section = classes
|
||||
try:
|
||||
with driver_tools.get_session(database=neo4j_db_name) as session:
|
||||
result = session.run(
|
||||
"MATCH (tt)-[:TIMETABLE_HAS_CLASS]->(c:SubjectClass) "
|
||||
"WHERE tt.uuid_string = $id "
|
||||
"RETURN c ORDER BY c.name",
|
||||
id=neo4j_node_id,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"neo4j_node_id": r["c"]["uuid_string"],
|
||||
"label": r["c"].get("name") or "Class",
|
||||
"node_type": "SubjectClass",
|
||||
"neo4j_db_name": neo4j_db_name,
|
||||
"is_section": False,
|
||||
"has_children": True,
|
||||
}
|
||||
for r in result
|
||||
]
|
||||
except Exception as e:
|
||||
logger.warning(f"Timetable children query failed: {e}")
|
||||
return []
|
||||
|
||||
if section_id == "curriculum" and neo4j_db_name:
|
||||
try:
|
||||
with driver_tools.get_session(database=neo4j_db_name) as session:
|
||||
result = session.run(
|
||||
"MATCH (s:KeyStageSyllabus) RETURN s ORDER BY s.key_stage"
|
||||
)
|
||||
rows = list(result)
|
||||
if not rows:
|
||||
result = session.run(
|
||||
"MATCH (s:YearGroupSyllabus) RETURN s ORDER BY s.year_group"
|
||||
)
|
||||
rows = list(result)
|
||||
return [
|
||||
{
|
||||
"neo4j_node_id": r[list(r.keys())[0]]["uuid_string"],
|
||||
"label": r[list(r.keys())[0]].get("name") or r[list(r.keys())[0]].get("key_stage") or "Syllabus",
|
||||
"node_type": list(r.keys())[0],
|
||||
"neo4j_db_name": neo4j_db_name,
|
||||
"is_section": False,
|
||||
"has_children": True,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
except Exception as e:
|
||||
logger.warning(f"Curriculum children query failed: {e}")
|
||||
return []
|
||||
|
||||
if section_id == "school" and neo4j_db_name:
|
||||
try:
|
||||
with driver_tools.get_session(database=neo4j_db_name) as session:
|
||||
result = session.run(
|
||||
"MATCH (s:School {uuid_string: $id})-[:HAS_DEPARTMENT]->(d) "
|
||||
"RETURN d ORDER BY d.name",
|
||||
id=neo4j_node_id,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"neo4j_node_id": r["d"]["uuid_string"],
|
||||
"label": r["d"].get("name") or "Department",
|
||||
"node_type": "Department",
|
||||
"neo4j_db_name": neo4j_db_name,
|
||||
"is_section": False,
|
||||
"has_children": True,
|
||||
}
|
||||
for r in result
|
||||
]
|
||||
except Exception as e:
|
||||
logger.warning(f"School children query failed: {e}")
|
||||
return []
|
||||
|
||||
# AcademicYear → terms
|
||||
if node_type == "AcademicYear" and neo4j_db_name:
|
||||
try:
|
||||
with driver_tools.get_session(database=neo4j_db_name) as session:
|
||||
result = session.run(
|
||||
"MATCH (ay:AcademicYear {uuid_string: $id})"
|
||||
"-[:ACADEMIC_YEAR_HAS_ACADEMIC_TERM]->(t:AcademicTerm) "
|
||||
"RETURN t ORDER BY toInteger(t.term_number)",
|
||||
id=neo4j_node_id,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"neo4j_node_id": r["t"]["uuid_string"],
|
||||
"label": r["t"]["term_name"],
|
||||
"node_type": "AcademicTerm",
|
||||
"neo4j_db_name": neo4j_db_name,
|
||||
"is_section": False,
|
||||
"has_children": True,
|
||||
}
|
||||
for r in result
|
||||
]
|
||||
except Exception as e:
|
||||
logger.warning(f"AcademicYear children failed: {e}")
|
||||
return []
|
||||
|
||||
# AcademicWeek → days (or TaughtLessons in timetable-term context)
|
||||
if node_type == "AcademicWeek" and neo4j_db_name:
|
||||
if section_id == "timetable-term" and user_email:
|
||||
# Supabase: get week date range from Neo4j, then query taught_lessons
|
||||
try:
|
||||
week_start = None
|
||||
with driver_tools.get_session(database=neo4j_db_name) as session:
|
||||
rec = session.run(
|
||||
"MATCH (w:AcademicWeek {uuid_string: $id}) "
|
||||
"RETURN w.start_date AS start_date",
|
||||
id=neo4j_node_id,
|
||||
).single()
|
||||
if rec:
|
||||
week_start = rec["start_date"]
|
||||
if week_start:
|
||||
from datetime import datetime, timedelta
|
||||
start_dt = datetime.strptime(str(week_start)[:10], "%Y-%m-%d")
|
||||
end_dt = start_dt + timedelta(days=6)
|
||||
sb = _sb()
|
||||
prof = sb.supabase.table("profiles").select("id").eq("email", user_email).single().execute()
|
||||
teacher_id = (prof.data or {}).get("id")
|
||||
if teacher_id:
|
||||
lessons = (
|
||||
sb.supabase.table("taught_lessons")
|
||||
.select("id, date, period_code, class_name, subject")
|
||||
.eq("teacher_profile_id", teacher_id)
|
||||
.gte("date", start_dt.strftime("%Y-%m-%d"))
|
||||
.lte("date", end_dt.strftime("%Y-%m-%d"))
|
||||
.order("date")
|
||||
.order("period_code")
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
return [
|
||||
{
|
||||
"neo4j_node_id": tl["id"],
|
||||
"label": "{} — {}".format(
|
||||
tl.get("period_code") or "",
|
||||
tl.get("class_name") or tl.get("subject") or "Lesson"
|
||||
),
|
||||
"node_type": "TaughtLesson",
|
||||
"neo4j_db_name": neo4j_db_name,
|
||||
"is_section": False,
|
||||
"has_children": False,
|
||||
}
|
||||
for tl in lessons
|
||||
]
|
||||
except Exception as e:
|
||||
logger.warning(f"AcademicWeek timetable-term Supabase lessons failed: {e}")
|
||||
return []
|
||||
try:
|
||||
with driver_tools.get_session(database=neo4j_db_name) as session:
|
||||
result = session.run(
|
||||
"MATCH (w:AcademicWeek {uuid_string: $id}) "
|
||||
"-[:ACADEMIC_WEEK_HAS_ACADEMIC_DAY]->(d:AcademicDay) "
|
||||
"RETURN d ORDER BY d.date",
|
||||
id=neo4j_node_id,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"neo4j_node_id": r["d"]["uuid_string"],
|
||||
"label": r["d"].get("date", ""),
|
||||
"node_type": "AcademicDay",
|
||||
"neo4j_db_name": neo4j_db_name,
|
||||
"is_section": False,
|
||||
"has_children": False,
|
||||
}
|
||||
for r in result
|
||||
]
|
||||
except Exception as e:
|
||||
logger.warning(f"AcademicWeek children failed: {e}")
|
||||
return []
|
||||
|
||||
# AcademicTerm → weeks
|
||||
if node_type == "AcademicTerm" and neo4j_db_name:
|
||||
try:
|
||||
with driver_tools.get_session(database=neo4j_db_name) as session:
|
||||
result = session.run(
|
||||
"MATCH (t:AcademicTerm {uuid_string: $id})"
|
||||
"-[:ACADEMIC_TERM_HAS_ACADEMIC_WEEK]->(w:AcademicWeek) "
|
||||
"RETURN w ORDER BY toInteger(w.academic_week_number)",
|
||||
id=neo4j_node_id,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"neo4j_node_id": r["w"]["uuid_string"],
|
||||
"label": "Week {}".format(r["w"].get("academic_week_number", r["w"].get("week_number", "?"))),
|
||||
"node_type": "AcademicWeek",
|
||||
"neo4j_db_name": neo4j_db_name,
|
||||
"section_id": section_id if section_id == "timetable-term" else "",
|
||||
"is_section": False,
|
||||
"has_children": True,
|
||||
}
|
||||
for r in result
|
||||
]
|
||||
except Exception as e:
|
||||
logger.warning(f"AcademicTerm children failed: {e}")
|
||||
return []
|
||||
|
||||
# SubjectClass → lessons
|
||||
if node_type == "SubjectClass" and neo4j_db_name:
|
||||
try:
|
||||
with driver_tools.get_session(database=neo4j_db_name) as session:
|
||||
result = session.run(
|
||||
"MATCH (c:SubjectClass {uuid_string: $id})-[:CLASS_HAS_LESSON]->(l) "
|
||||
"RETURN l ORDER BY l.date, l.start_time LIMIT 50",
|
||||
id=neo4j_node_id,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"neo4j_node_id": r["l"]["uuid_string"],
|
||||
"label": r["l"].get("title") or r["l"].get("period_code") or "Lesson",
|
||||
"node_type": "TimetableLesson",
|
||||
"neo4j_db_name": neo4j_db_name,
|
||||
"is_section": False,
|
||||
"has_children": False,
|
||||
}
|
||||
for r in result
|
||||
]
|
||||
except Exception as e:
|
||||
logger.warning(f"Class lessons query failed: {e}")
|
||||
|
||||
return []
|
||||
|
||||
|
||||
# ─── Endpoints ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/tree")
|
||||
async def get_teacher_graph_tree(
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
user_email = credentials.get("email", "")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=403, detail="Could not extract user_id from token")
|
||||
|
||||
teacher_db = _user_to_teacher_db(user_id)
|
||||
|
||||
user_node = _query_user_node(teacher_db) or {
|
||||
"neo4j_node_id": user_id,
|
||||
"label": "My Workspace",
|
||||
"node_type": "User",
|
||||
"neo4j_db_name": teacher_db,
|
||||
"is_section": False,
|
||||
"has_children": True,
|
||||
}
|
||||
|
||||
supabase_institute_id, institute_db, teacher_node_uuid = _resolve_institute(user_id, user_email)
|
||||
|
||||
sections = [
|
||||
_build_calendar_section(),
|
||||
_build_timetable_section(user_id, supabase_institute_id, institute_db, teacher_node_uuid),
|
||||
_build_classes_section(user_id, supabase_institute_id, institute_db),
|
||||
_build_curriculum_section(institute_db),
|
||||
_build_journal_section(teacher_db),
|
||||
_build_planner_section(teacher_db),
|
||||
]
|
||||
|
||||
if institute_db:
|
||||
sections.append(_build_school_section(institute_db))
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"tree": {**user_node, "children": sections},
|
||||
"meta": {
|
||||
"institute_db": institute_db,
|
||||
"teacher_linked": institute_db is not None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/node/children")
|
||||
async def get_node_children(
|
||||
neo4j_node_id: str,
|
||||
neo4j_db_name: str,
|
||||
node_type: str,
|
||||
section_id: str = "",
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
user_email = credentials.get("email", "")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=403, detail="Could not extract user_id from token")
|
||||
_require_allowed_neo4j_db(neo4j_db_name, node_type, section_id, user_id, user_email)
|
||||
children = _get_children_for_node(neo4j_node_id, neo4j_db_name, node_type, section_id, user_email)
|
||||
return {"status": "success", "children": children}
|
||||
|
||||
|
||||
@router.get("/calendar/academic")
|
||||
async def get_academic_calendar(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
user_email = credentials.get("email", "")
|
||||
_, institute_db, _ = _resolve_institute(user_id, user_email)
|
||||
if not institute_db:
|
||||
return {"status": "no_school", "terms": []}
|
||||
try:
|
||||
with driver_tools.get_session(database=institute_db) as s:
|
||||
if not s.run("MATCH (ay:AcademicYear) RETURN ay LIMIT 1").single():
|
||||
return {"status": "empty", "terms": [], "institute_db": institute_db}
|
||||
terms = []
|
||||
for tr in s.run(
|
||||
"MATCH (ay:AcademicYear)-[:ACADEMIC_YEAR_HAS_ACADEMIC_TERM]->(t:AcademicTerm) "
|
||||
"RETURN t ORDER BY toInteger(t.term_number)"
|
||||
):
|
||||
t = tr["t"]
|
||||
with driver_tools.get_session(database=institute_db) as s2:
|
||||
weeks = [{
|
||||
"neo4j_node_id": w["w"]["uuid_string"],
|
||||
"label": f"Week {w['w']['academic_week_number']}",
|
||||
"node_type": "AcademicWeek",
|
||||
"neo4j_db_name": institute_db,
|
||||
"is_section": False,
|
||||
"has_children": True,
|
||||
} for w in s2.run(
|
||||
"MATCH (t:AcademicTerm {uuid_string: $tid})-[:ACADEMIC_TERM_HAS_ACADEMIC_WEEK]->(w:AcademicWeek) "
|
||||
"RETURN w ORDER BY toInteger(w.academic_week_number)",
|
||||
tid=t["uuid_string"]
|
||||
)]
|
||||
terms.append({
|
||||
"neo4j_node_id": t["uuid_string"],
|
||||
"label": t["term_name"],
|
||||
"node_type": "AcademicTerm",
|
||||
"neo4j_db_name": institute_db,
|
||||
"is_section": False,
|
||||
"has_children": len(weeks) > 0,
|
||||
"children": weeks,
|
||||
})
|
||||
return {"status": "populated", "terms": terms, "institute_db": institute_db}
|
||||
except Exception as e:
|
||||
logger.error(f"Academic calendar query failed: {e}")
|
||||
return {"status": "error", "terms": []}
|
||||
@@ -0,0 +1,372 @@
|
||||
"""
|
||||
Invitations & People Router.
|
||||
|
||||
POST /users/invite — school admin sends magic-link invitation
|
||||
GET /users/invitations — list invitations for the school
|
||||
DELETE /users/invitations/{id} — cancel a pending invitation
|
||||
POST /users/invitations/{id}/resend — re-send magic link
|
||||
GET /users/staff — list current staff members
|
||||
GET /users/students — list current student members
|
||||
"""
|
||||
import os
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, EmailStr
|
||||
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()
|
||||
|
||||
VALID_ROLES = {"teacher", "student", "school_admin", "department_head"}
|
||||
STAFF_ROLES = {"teacher", "school_admin", "department_head"}
|
||||
|
||||
|
||||
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 _require_school_admin(user_id: str, institute_id: str) -> None:
|
||||
try:
|
||||
sb = _sb()
|
||||
m = (
|
||||
sb.supabase.table("institute_memberships")
|
||||
.select("role")
|
||||
.eq("profile_id", user_id)
|
||||
.eq("institute_id", institute_id)
|
||||
.single()
|
||||
.execute()
|
||||
)
|
||||
role = (m.data or {}).get("role", "")
|
||||
if role not in ("school_admin", "department_head"):
|
||||
raise HTTPException(status_code=403, detail="School admin access required")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(status_code=403, detail="Could not verify school admin status")
|
||||
|
||||
|
||||
# ─── Request models ───────────────────────────────────────────────────────────
|
||||
|
||||
class InviteRequest(BaseModel):
|
||||
email: str
|
||||
role: str # teacher | student | school_admin | department_head
|
||||
metadata: Optional[Dict[str, Any]] = None # year_group, subject, department, etc.
|
||||
|
||||
|
||||
# ─── Invite ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/invite")
|
||||
async def invite_user(
|
||||
body: InviteRequest,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create an invitation row and send a Supabase magic-link invite email.
|
||||
Idempotent: if a pending invitation already exists for the same email/school,
|
||||
it is returned (use /resend to refresh the magic link).
|
||||
"""
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
_require_school_admin(user_id, institute_id)
|
||||
|
||||
if body.role not in VALID_ROLES:
|
||||
raise HTTPException(status_code=400, detail=f"role must be one of {sorted(VALID_ROLES)}")
|
||||
|
||||
email = body.email.strip().lower()
|
||||
sb = _sb()
|
||||
|
||||
# Check for existing pending invitation
|
||||
existing = (
|
||||
sb.supabase.table("invitations")
|
||||
.select("id,status,email,role,created_at,expires_at")
|
||||
.eq("institute_id", institute_id)
|
||||
.eq("email", email)
|
||||
.eq("status", "pending")
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
if existing:
|
||||
return {
|
||||
"status": "already_pending",
|
||||
"invitation": existing[0],
|
||||
"message": "A pending invitation already exists. Use /resend to refresh the magic link.",
|
||||
}
|
||||
|
||||
# Insert invitation row
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(days=7)).isoformat()
|
||||
inv_data = {
|
||||
"institute_id": institute_id,
|
||||
"email": email,
|
||||
"role": body.role,
|
||||
"invited_by": user_id,
|
||||
"expires_at": expires_at,
|
||||
"status": "pending",
|
||||
"metadata": body.metadata or {},
|
||||
}
|
||||
inv_res = sb.supabase.table("invitations").insert(inv_data).execute()
|
||||
if not inv_res.data:
|
||||
raise HTTPException(status_code=500, detail="Failed to create invitation record")
|
||||
invitation = inv_res.data[0]
|
||||
|
||||
# Send magic link via Supabase Auth admin
|
||||
try:
|
||||
sb.supabase.auth.admin.invite_user_by_email(
|
||||
email,
|
||||
options={
|
||||
"data": {
|
||||
"invitation_id": invitation["id"],
|
||||
"institute_id": institute_id,
|
||||
"role": body.role,
|
||||
**(body.metadata or {}),
|
||||
}
|
||||
},
|
||||
)
|
||||
magic_link_sent = True
|
||||
except Exception as e:
|
||||
logger.warning(f"Magic link send failed for {email}: {e}")
|
||||
magic_link_sent = False
|
||||
|
||||
logger.info(f"Invited {email} as {body.role} to school {institute_id} by {user_id}")
|
||||
return {
|
||||
"status": "ok",
|
||||
"invitation": invitation,
|
||||
"magic_link_sent": magic_link_sent,
|
||||
}
|
||||
|
||||
|
||||
# ─── List invitations ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/invitations")
|
||||
async def list_invitations(
|
||||
role: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None, description="pending|accepted|expired|cancelled"),
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
_require_school_admin(user_id, institute_id)
|
||||
|
||||
sb = _sb()
|
||||
q = (
|
||||
sb.supabase.table("invitations")
|
||||
.select("id,email,role,status,invited_by,expires_at,created_at,metadata")
|
||||
.eq("institute_id", institute_id)
|
||||
.order("created_at", desc=True)
|
||||
)
|
||||
if role:
|
||||
q = q.eq("role", role)
|
||||
if status:
|
||||
q = q.eq("status", status)
|
||||
|
||||
rows = q.execute().data or []
|
||||
|
||||
# Mark expired rows (status still 'pending' but past expires_at)
|
||||
now = datetime.now(timezone.utc)
|
||||
for row in rows:
|
||||
if row["status"] == "pending":
|
||||
try:
|
||||
exp = datetime.fromisoformat(row["expires_at"].replace("Z", "+00:00"))
|
||||
if exp < now:
|
||||
row["status"] = "expired"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"status": "ok", "invitations": rows, "total": len(rows)}
|
||||
|
||||
|
||||
# ─── Cancel invitation ────────────────────────────────────────────────────────
|
||||
|
||||
@router.delete("/invitations/{invitation_id}")
|
||||
async def cancel_invitation(
|
||||
invitation_id: str,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
_require_school_admin(user_id, institute_id)
|
||||
|
||||
sb = _sb()
|
||||
res = (
|
||||
sb.supabase.table("invitations")
|
||||
.update({"status": "cancelled"})
|
||||
.eq("id", invitation_id)
|
||||
.eq("institute_id", institute_id)
|
||||
.eq("status", "pending")
|
||||
.execute()
|
||||
)
|
||||
if not res.data:
|
||||
raise HTTPException(status_code=404, detail="Pending invitation not found")
|
||||
return {"status": "ok", "invitation": res.data[0]}
|
||||
|
||||
|
||||
# ─── Resend invitation ────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/invitations/{invitation_id}/resend")
|
||||
async def resend_invitation(
|
||||
invitation_id: str,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
"""Re-trigger the magic link for a pending (or expired) invitation."""
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
_require_school_admin(user_id, institute_id)
|
||||
|
||||
sb = _sb()
|
||||
inv = (
|
||||
sb.supabase.table("invitations")
|
||||
.select("*")
|
||||
.eq("id", invitation_id)
|
||||
.eq("institute_id", institute_id)
|
||||
.single()
|
||||
.execute()
|
||||
).data
|
||||
if not inv:
|
||||
raise HTTPException(status_code=404, detail="Invitation not found")
|
||||
if inv["status"] == "accepted":
|
||||
raise HTTPException(status_code=400, detail="Invitation already accepted")
|
||||
if inv["status"] == "cancelled":
|
||||
raise HTTPException(status_code=400, detail="Invitation was cancelled — create a new one")
|
||||
|
||||
# Refresh expiry + status
|
||||
new_expires = (datetime.now(timezone.utc) + timedelta(days=7)).isoformat()
|
||||
sb.supabase.table("invitations").update({
|
||||
"expires_at": new_expires,
|
||||
"status": "pending",
|
||||
}).eq("id", invitation_id).execute()
|
||||
|
||||
# Re-send magic link
|
||||
try:
|
||||
sb.supabase.auth.admin.invite_user_by_email(
|
||||
inv["email"],
|
||||
options={
|
||||
"data": {
|
||||
"invitation_id": invitation_id,
|
||||
"institute_id": institute_id,
|
||||
"role": inv["role"],
|
||||
**inv.get("metadata", {}),
|
||||
}
|
||||
},
|
||||
)
|
||||
magic_link_sent = True
|
||||
except Exception as e:
|
||||
logger.warning(f"Resend magic link failed for {inv['email']}: {e}")
|
||||
magic_link_sent = False
|
||||
|
||||
return {"status": "ok", "magic_link_sent": magic_link_sent, "new_expires_at": new_expires}
|
||||
|
||||
|
||||
# ─── Staff list ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/staff")
|
||||
async def list_staff(
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
"""List all staff members (teachers, admins, department heads) in the school."""
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
_require_school_admin(user_id, institute_id)
|
||||
|
||||
sb = _sb()
|
||||
members = (
|
||||
sb.supabase.table("institute_memberships")
|
||||
.select("profile_id,role,joined_at")
|
||||
.eq("institute_id", institute_id)
|
||||
.in_("role", list(STAFF_ROLES))
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
|
||||
if not members:
|
||||
return {"status": "ok", "staff": [], "total": 0}
|
||||
|
||||
profile_ids = [m["profile_id"] for m in members]
|
||||
profiles = (
|
||||
sb.supabase.table("profiles")
|
||||
.select("id,email,username,display_name")
|
||||
.in_("id", profile_ids)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
profile_map = {p["id"]: p for p in profiles}
|
||||
|
||||
staff = []
|
||||
for m in members:
|
||||
p = profile_map.get(m["profile_id"], {})
|
||||
staff.append({
|
||||
"profile_id": m["profile_id"],
|
||||
"email": p.get("email"),
|
||||
"username": p.get("username"),
|
||||
"display_name": p.get("display_name"),
|
||||
"role": m["role"],
|
||||
"joined_at": m["joined_at"],
|
||||
})
|
||||
|
||||
return {"status": "ok", "staff": staff, "total": len(staff)}
|
||||
|
||||
|
||||
# ─── Student list ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/students")
|
||||
async def list_students(
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
"""List all student members in the school."""
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
_require_school_admin(user_id, institute_id)
|
||||
|
||||
sb = _sb()
|
||||
members = (
|
||||
sb.supabase.table("institute_memberships")
|
||||
.select("profile_id,role,joined_at")
|
||||
.eq("institute_id", institute_id)
|
||||
.eq("role", "student")
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
|
||||
if not members:
|
||||
return {"status": "ok", "students": [], "total": 0}
|
||||
|
||||
profile_ids = [m["profile_id"] for m in members]
|
||||
profiles = (
|
||||
sb.supabase.table("profiles")
|
||||
.select("id,email,username,display_name")
|
||||
.in_("id", profile_ids)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
profile_map = {p["id"]: p for p in profiles}
|
||||
|
||||
students = []
|
||||
for m in members:
|
||||
p = profile_map.get(m["profile_id"], {})
|
||||
students.append({
|
||||
"profile_id": m["profile_id"],
|
||||
"email": p.get("email"),
|
||||
"username": p.get("username"),
|
||||
"display_name": p.get("display_name"),
|
||||
"role": m["role"],
|
||||
"joined_at": m["joined_at"],
|
||||
})
|
||||
|
||||
return {"status": "ok", "students": students, "total": len(students)}
|
||||
@@ -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}
|
||||
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Platform Admin Router — super_admin / platform_admin operations.
|
||||
|
||||
GET /admin/schools — list all institutes with member + calendar counts
|
||||
GET /admin/stats — platform-level summary
|
||||
"""
|
||||
import os
|
||||
from typing import Any, Dict, List
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from modules.logger_tool import initialise_logger
|
||||
from modules.auth.supabase_bearer import SupabaseBearer
|
||||
from modules.auth.platform_admin import require_platform_admin
|
||||
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _sb() -> SupabaseServiceRoleClient:
|
||||
return SupabaseServiceRoleClient()
|
||||
|
||||
|
||||
@router.get("/schools")
|
||||
async def list_all_schools(
|
||||
_: dict = Depends(require_platform_admin),
|
||||
) -> Dict[str, Any]:
|
||||
"""List every institute with basic counts. Platform admin only."""
|
||||
sb = _sb()
|
||||
|
||||
institutes = (
|
||||
sb.supabase.table("institutes")
|
||||
.select("id,name,urn,website,status,created_at,neo4j_uuid_string")
|
||||
.order("name")
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
|
||||
if not institutes:
|
||||
return {"status": "ok", "schools": [], "total": 0}
|
||||
|
||||
inst_ids = [i["id"] for i in institutes]
|
||||
|
||||
# Member counts per institute
|
||||
all_members = (
|
||||
sb.supabase.table("institute_memberships")
|
||||
.select("institute_id,role")
|
||||
.in_("institute_id", inst_ids)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
from collections import defaultdict
|
||||
member_counts: Dict[str, Dict[str, int]] = defaultdict(lambda: {"staff": 0, "students": 0})
|
||||
staff_roles = {"teacher", "school_admin", "department_head"}
|
||||
for m in all_members:
|
||||
iid = m["institute_id"]
|
||||
if m["role"] in staff_roles:
|
||||
member_counts[iid]["staff"] += 1
|
||||
elif m["role"] == "student":
|
||||
member_counts[iid]["students"] += 1
|
||||
|
||||
# Calendar presence per institute
|
||||
term_rows = (
|
||||
sb.supabase.table("academic_terms")
|
||||
.select("institute_id")
|
||||
.in_("institute_id", inst_ids)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
has_calendar = {r["institute_id"] for r in term_rows}
|
||||
|
||||
# Pending invitations count
|
||||
inv_rows = (
|
||||
sb.supabase.table("invitations")
|
||||
.select("institute_id")
|
||||
.eq("status", "pending")
|
||||
.in_("institute_id", inst_ids)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
from collections import Counter
|
||||
inv_counts = Counter(r["institute_id"] for r in inv_rows)
|
||||
|
||||
schools = []
|
||||
for inst in institutes:
|
||||
iid = inst["id"]
|
||||
mc = member_counts.get(iid, {})
|
||||
schools.append({
|
||||
**inst,
|
||||
"staff_count": mc.get("staff", 0),
|
||||
"student_count": mc.get("students", 0),
|
||||
"has_calendar": iid in has_calendar,
|
||||
"pending_invitations": inv_counts.get(iid, 0),
|
||||
})
|
||||
|
||||
return {"status": "ok", "schools": schools, "total": len(schools)}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def platform_stats(
|
||||
_: dict = Depends(require_platform_admin),
|
||||
) -> Dict[str, Any]:
|
||||
"""High-level platform counts. Platform admin only."""
|
||||
sb = _sb()
|
||||
|
||||
inst_count = len(
|
||||
sb.supabase.table("institutes").select("id").execute().data or []
|
||||
)
|
||||
profile_count = len(
|
||||
sb.supabase.table("profiles").select("id").execute().data or []
|
||||
)
|
||||
lesson_count = len(
|
||||
sb.supabase.table("taught_lessons").select("id").execute().data or []
|
||||
)
|
||||
inv_count = len(
|
||||
sb.supabase.table("invitations").select("id").eq("status", "pending").execute().data or []
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"schools": inst_count,
|
||||
"profiles": profile_count,
|
||||
"taught_lessons": lesson_count,
|
||||
"pending_invitations": inv_count,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/reset")
|
||||
async def reset_environment(
|
||||
_: dict = Depends(require_platform_admin),
|
||||
) -> Dict[str, Any]:
|
||||
"""DESTRUCTIVE: wipe all test data. Neo4j + Supabase. Platform admin only."""
|
||||
import asyncio
|
||||
from run.initialization.reset_environment import reset as _reset
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(None, _reset)
|
||||
return {"status": "ok", **result}
|
||||
|
||||
|
||||
@router.post("/seed")
|
||||
async def seed_environment(
|
||||
_: dict = Depends(require_platform_admin),
|
||||
) -> Dict[str, Any]:
|
||||
"""Idempotent rebuild: both schools, global calendar, 20 test accounts. Platform admin only."""
|
||||
import asyncio
|
||||
from run.initialization.seed_environment import seed as _seed
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(None, _seed)
|
||||
return {"status": "ok", **result}
|
||||
|
||||
|
||||
@router.post("/seed-timetable")
|
||||
async def seed_greenfield_timetable(
|
||||
_: dict = Depends(require_platform_admin),
|
||||
) -> Dict[str, Any]:
|
||||
"""Seed full timetable + taught lessons for Greenfield Academy. Platform admin only."""
|
||||
import asyncio
|
||||
from run.initialization.seed_greenfield_timetable import seed as _seed
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(None, _seed)
|
||||
return {"status": "ok", **result}
|
||||
@@ -0,0 +1,603 @@
|
||||
"""
|
||||
School Router — school status, search, register, and admin-editable info.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import uuid as uuid_lib
|
||||
from typing import Dict, Any, Optional, List
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
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
|
||||
import modules.database.tools.neo4j_driver_tools as driver_tools
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_sb():
|
||||
return SupabaseServiceRoleClient()
|
||||
|
||||
def _institute_db(neo4j_uuid: str) -> str:
|
||||
return f"cc.institutes.{neo4j_uuid}"
|
||||
|
||||
def _find_institute_db_by_email(user_email: str) -> Optional[str]:
|
||||
"""Fallback: scan all institute DBs for the teacher's email."""
|
||||
try:
|
||||
with driver_tools.get_session(database="system") as s:
|
||||
dbs = [r["name"] for r in s.run(
|
||||
"SHOW DATABASES YIELD name "
|
||||
"WHERE name STARTS WITH 'cc.institutes.' "
|
||||
"AND NOT name ENDS WITH '.curriculum' RETURN name"
|
||||
)]
|
||||
for db in dbs:
|
||||
try:
|
||||
with driver_tools.get_session(database=db) as s:
|
||||
rec = s.run(
|
||||
"MATCH (t:Teacher) WHERE t.worker_email = $e "
|
||||
"RETURN t.uuid_string AS uuid LIMIT 1",
|
||||
e=user_email
|
||||
).single()
|
||||
if rec:
|
||||
return db
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning(f"Institute DB scan failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ─── Status endpoint ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/status")
|
||||
async def get_school_status(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]:
|
||||
"""Return the current user's school role, school info, and calendar/timetable setup status."""
|
||||
user_id = credentials.get("sub", "")
|
||||
user_email = credentials.get("email", "")
|
||||
|
||||
try:
|
||||
sb = _get_sb()
|
||||
|
||||
p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
|
||||
school_id = (p.data or {}).get("school_id")
|
||||
|
||||
# Fallback: if profiles.school_id not set, check institute_memberships directly
|
||||
if not school_id:
|
||||
m_fb = sb.supabase.table("institute_memberships").select("institute_id,role").eq("profile_id", user_id).single().execute()
|
||||
if m_fb.data and m_fb.data.get("institute_id"):
|
||||
school_id = m_fb.data["institute_id"]
|
||||
user_role = m_fb.data.get("role") or "teacher"
|
||||
# Self-heal: write school_id back to profile
|
||||
try:
|
||||
sb.supabase.table("profiles").update({"school_id": str(school_id)}).eq("id", user_id).execute()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
return {"status": "no_school"}
|
||||
else:
|
||||
m = sb.supabase.table("institute_memberships").select("role").eq("profile_id", user_id).eq("institute_id", school_id).single().execute()
|
||||
user_role = ((m.data or {}).get("role") or "teacher")
|
||||
|
||||
i = sb.supabase.table("institutes").select("id,name,urn,website,address,metadata,neo4j_uuid_string").eq("id", school_id).single().execute()
|
||||
inst = i.data or {}
|
||||
neo4j_uuid = inst.get("neo4j_uuid_string")
|
||||
meta = inst.get("metadata") or {}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Supabase school status query failed: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
if neo4j_uuid:
|
||||
inst_db = _institute_db(neo4j_uuid)
|
||||
else:
|
||||
inst_db = _find_institute_db_by_email(user_email)
|
||||
if not inst_db:
|
||||
return {"status": "no_school"}
|
||||
|
||||
school_has_calendar = False
|
||||
teacher_has_timetable = False
|
||||
timetable_id = None
|
||||
periods_template = None
|
||||
|
||||
try:
|
||||
with driver_tools.get_session(database=inst_db) as s:
|
||||
ay = s.run("MATCH (ay:AcademicYear) RETURN ay LIMIT 1").single()
|
||||
school_has_calendar = ay is not None
|
||||
|
||||
st = s.run(
|
||||
"MATCH (st:SchoolTimetable) WHERE st.periods_template IS NOT NULL "
|
||||
"RETURN st.periods_template AS p LIMIT 1"
|
||||
).single()
|
||||
if st:
|
||||
periods_template = json.loads(st["p"])
|
||||
|
||||
t = s.run(
|
||||
"MATCH (t:Teacher) WHERE t.worker_email = $e "
|
||||
"RETURN t.uuid_string AS uuid LIMIT 1",
|
||||
e=user_email
|
||||
).single()
|
||||
if t:
|
||||
teacher_uuid = t["uuid"]
|
||||
tt = s.run(
|
||||
"MATCH (t:Teacher {uuid_string: $u})-[:HAS_TIMETABLE]->(tt:TeacherTimetable) "
|
||||
"RETURN tt.uuid_string AS id LIMIT 1",
|
||||
u=teacher_uuid
|
||||
).single()
|
||||
if tt:
|
||||
teacher_has_timetable = True
|
||||
timetable_id = tt["id"]
|
||||
except Exception as e:
|
||||
logger.warning(f"Neo4j school status check failed for {inst_db}: {e}")
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"user_role": user_role,
|
||||
"school_id": str(school_id),
|
||||
"institute_db": inst_db,
|
||||
"school_has_calendar": school_has_calendar,
|
||||
"teacher_has_timetable": teacher_has_timetable,
|
||||
"timetable_id": timetable_id,
|
||||
"periods_template": periods_template,
|
||||
"school_info": {
|
||||
"name": inst.get("name", ""),
|
||||
"urn": inst.get("urn", ""),
|
||||
"website": inst.get("website", ""),
|
||||
"address": inst.get("address") or {},
|
||||
"headteacher": meta.get("headteacher", ""),
|
||||
"term_dates_url": meta.get("term_dates_url", ""),
|
||||
"staff_list_url": meta.get("staff_list_url", ""),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── School info update ───────────────────────────────────────────────────────
|
||||
|
||||
class SchoolInfoUpdate(BaseModel):
|
||||
headteacher: Optional[str] = None
|
||||
term_dates_url: Optional[str] = None
|
||||
staff_list_url: Optional[str] = None
|
||||
|
||||
@router.patch("/info")
|
||||
async def update_school_info(
|
||||
body: SchoolInfoUpdate,
|
||||
credentials: dict = Depends(SupabaseBearer())
|
||||
) -> Dict[str, Any]:
|
||||
"""Admin: update school metadata fields stored in institutes.metadata jsonb."""
|
||||
user_id = credentials.get("sub", "")
|
||||
try:
|
||||
sb = _get_sb()
|
||||
p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
|
||||
school_id = (p.data or {}).get("school_id")
|
||||
if not school_id:
|
||||
return {"status": "error", "message": "No school linked"}
|
||||
|
||||
m = sb.supabase.table("institute_memberships").select("role").eq("profile_id", user_id).eq("institute_id", school_id).single().execute()
|
||||
role = ((m.data or {}).get("role") or "teacher")
|
||||
if role != "school_admin":
|
||||
return {"status": "error", "message": "school_admin role required"}
|
||||
|
||||
i = sb.supabase.table("institutes").select("metadata").eq("id", school_id).single().execute()
|
||||
meta = dict((i.data or {}).get("metadata") or {})
|
||||
if body.headteacher is not None:
|
||||
meta["headteacher"] = body.headteacher
|
||||
if body.term_dates_url is not None:
|
||||
meta["term_dates_url"] = body.term_dates_url
|
||||
if body.staff_list_url is not None:
|
||||
meta["staff_list_url"] = body.staff_list_url
|
||||
|
||||
sb.supabase.table("institutes").update({"metadata": meta}).eq("id", school_id).execute()
|
||||
return {"status": "ok"}
|
||||
except Exception as e:
|
||||
logger.error(f"School info update failed: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
# ─── GAIS school search ───────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/search")
|
||||
async def search_schools(
|
||||
q: str = Query(..., min_length=2, description="School name, URN, or postcode"),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
status: str = Query("Open", description="Filter by establishment status"),
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
"""Search GAIS school reference data by name, URN, or postcode."""
|
||||
try:
|
||||
sb = _get_sb()
|
||||
query = sb.supabase.table("gais_schools").select(
|
||||
"urn,name,status,phase,type,street,locality,town,county,postcode,"
|
||||
"website,telephone,head_title,head_first_name,head_last_name,"
|
||||
"la_code,la_name,number_of_pupils,gender,religious_character,region"
|
||||
)
|
||||
if status:
|
||||
query = query.eq("status", status)
|
||||
|
||||
q_stripped = q.strip()
|
||||
if q_stripped.isdigit():
|
||||
# URN exact match
|
||||
query = query.eq("urn", q_stripped)
|
||||
else:
|
||||
# Name / postcode trigram search — use ilike on name first, fallback includes postcode
|
||||
query = query.ilike("name", f"%{q_stripped}%")
|
||||
|
||||
result = query.limit(limit).execute()
|
||||
return {"status": "ok", "schools": result.data or [], "count": len(result.data or [])}
|
||||
except Exception as e:
|
||||
logger.error(f"School search failed: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
# ─── School registration (onboarding) ────────────────────────────────────────
|
||||
|
||||
class SchoolRegisterBody(BaseModel):
|
||||
urn: str
|
||||
name: str
|
||||
address: Optional[Dict[str, Any]] = None
|
||||
website: Optional[str] = None
|
||||
headteacher: Optional[str] = None
|
||||
|
||||
@router.post("/register")
|
||||
async def register_school(
|
||||
body: SchoolRegisterBody,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Onboarding: create an institute record from a GAIS-selected school,
|
||||
provision the Neo4j database, and link the calling user as school_admin.
|
||||
"""
|
||||
user_id = credentials.get("sub", "")
|
||||
user_email = credentials.get("email", "")
|
||||
|
||||
try:
|
||||
sb = _get_sb()
|
||||
|
||||
# Prevent duplicate registration
|
||||
existing = sb.supabase.table("institutes").select("id").eq("urn", body.urn).execute()
|
||||
if existing.data:
|
||||
school_id = existing.data[0]["id"]
|
||||
# Still link user if not already linked
|
||||
_ensure_membership(sb, user_id, school_id, "school_admin")
|
||||
sb.supabase.table("profiles").update({"school_id": str(school_id)}).eq("id", user_id).execute()
|
||||
return {"status": "already_exists", "school_id": str(school_id)}
|
||||
|
||||
# Build metadata
|
||||
meta: Dict[str, Any] = {}
|
||||
if body.headteacher:
|
||||
meta["headteacher"] = body.headteacher
|
||||
|
||||
institute_data: Dict[str, Any] = {
|
||||
"name": body.name,
|
||||
"urn": body.urn,
|
||||
"status": "active",
|
||||
"address": body.address or {},
|
||||
"website": body.website or "",
|
||||
"metadata": meta,
|
||||
}
|
||||
|
||||
ins_result = sb.supabase.table("institutes").insert(institute_data).execute()
|
||||
if not ins_result.data:
|
||||
return {"status": "error", "message": "Failed to create institute record"}
|
||||
|
||||
school_id = ins_result.data[0]["id"]
|
||||
|
||||
# Provision Neo4j institute database
|
||||
try:
|
||||
from modules.database.services.provisioning_service import ProvisioningService
|
||||
ps = ProvisioningService()
|
||||
ps.ensure_school(school_id)
|
||||
# Reload institute to get the neo4j_uuid_string set by provisioning
|
||||
inst = sb.supabase.table("institutes").select("neo4j_uuid_string").eq("id", school_id).single().execute()
|
||||
neo4j_uuid = (inst.data or {}).get("neo4j_uuid_string")
|
||||
except Exception as prov_err:
|
||||
logger.warning(f"Neo4j provisioning failed for {school_id}: {prov_err}")
|
||||
neo4j_uuid = None
|
||||
|
||||
# Link user as school_admin
|
||||
_ensure_membership(sb, user_id, school_id, "school_admin")
|
||||
sb.supabase.table("profiles").update({"school_id": str(school_id)}).eq("id", user_id).execute()
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"school_id": str(school_id),
|
||||
"neo4j_uuid": neo4j_uuid,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"School registration failed: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
def _ensure_membership(sb: SupabaseServiceRoleClient, user_id: str, school_id: str, role: str) -> None:
|
||||
existing = sb.supabase.table("institute_memberships").select("id").eq("profile_id", user_id).eq("institute_id", school_id).execute()
|
||||
if not existing.data:
|
||||
sb.supabase.table("institute_memberships").insert({
|
||||
"profile_id": user_id,
|
||||
"institute_id": school_id,
|
||||
"role": role,
|
||||
}).execute()
|
||||
|
||||
|
||||
# ─── School Overview ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/overview")
|
||||
async def get_school_overview(
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Summary dashboard for school admins: staff/student/class counts,
|
||||
calendar snapshot (terms, total academic days, current/next term).
|
||||
"""
|
||||
user_id = credentials.get("sub", "")
|
||||
sb = _get_sb()
|
||||
|
||||
p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
|
||||
school_id = str((p.data or {}).get("school_id") or "")
|
||||
if not school_id:
|
||||
raise HTTPException(status_code=400, detail="User is not linked to a school")
|
||||
|
||||
# Role check
|
||||
mem = (
|
||||
sb.supabase.table("institute_memberships")
|
||||
.select("role")
|
||||
.eq("profile_id", user_id)
|
||||
.eq("institute_id", school_id)
|
||||
.single()
|
||||
.execute()
|
||||
)
|
||||
user_role = (mem.data or {}).get("role", "teacher")
|
||||
|
||||
# Counts
|
||||
staff_roles = ["teacher", "school_admin", "department_head"]
|
||||
staff_rows = (
|
||||
sb.supabase.table("institute_memberships")
|
||||
.select("profile_id", count="exact")
|
||||
.eq("institute_id", school_id)
|
||||
.in_("role", staff_roles)
|
||||
.execute()
|
||||
)
|
||||
student_rows = (
|
||||
sb.supabase.table("institute_memberships")
|
||||
.select("profile_id", count="exact")
|
||||
.eq("institute_id", school_id)
|
||||
.eq("role", "student")
|
||||
.execute()
|
||||
)
|
||||
class_rows = (
|
||||
sb.supabase.table("classes")
|
||||
.select("id", count="exact")
|
||||
.eq("institute_id", school_id)
|
||||
.eq("is_active", True)
|
||||
.execute()
|
||||
)
|
||||
|
||||
# Calendar snapshot from academic_terms
|
||||
terms = (
|
||||
sb.supabase.table("academic_terms")
|
||||
.select("id,term_name,term_number,start_date,end_date")
|
||||
.eq("institute_id", school_id)
|
||||
.order("term_number")
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
|
||||
# Academic day counts per term
|
||||
if terms:
|
||||
term_ids = [t["id"] for t in terms]
|
||||
day_counts_res = (
|
||||
sb.supabase.table("academic_days")
|
||||
.select("academic_term_id", count="exact")
|
||||
.eq("institute_id", school_id)
|
||||
.eq("day_type", "Academic")
|
||||
.in_("academic_term_id", term_ids)
|
||||
.execute()
|
||||
)
|
||||
# Supabase doesn't group-by server-side; count manually per term
|
||||
all_days = (
|
||||
sb.supabase.table("academic_days")
|
||||
.select("academic_term_id,day_type")
|
||||
.eq("institute_id", school_id)
|
||||
.in_("academic_term_id", term_ids)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
from collections import defaultdict
|
||||
academic_day_count: Dict[str, int] = defaultdict(int)
|
||||
total_day_count: Dict[str, int] = defaultdict(int)
|
||||
for d in all_days:
|
||||
total_day_count[d["academic_term_id"]] += 1
|
||||
if d["day_type"] == "Academic":
|
||||
academic_day_count[d["academic_term_id"]] += 1
|
||||
|
||||
from datetime import date
|
||||
today_str = str(date.today())
|
||||
for t in terms:
|
||||
t["academic_days"] = academic_day_count.get(t["id"], 0)
|
||||
t["total_days"] = total_day_count.get(t["id"], 0)
|
||||
if t["start_date"] <= today_str <= t["end_date"]:
|
||||
t["is_current"] = True
|
||||
else:
|
||||
t["is_current"] = False
|
||||
|
||||
pending_invites = (
|
||||
sb.supabase.table("invitations")
|
||||
.select("id", count="exact")
|
||||
.eq("institute_id", school_id)
|
||||
.eq("status", "pending")
|
||||
.execute()
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"user_role": user_role,
|
||||
"counts": {
|
||||
"staff": staff_rows.count or 0,
|
||||
"students": student_rows.count or 0,
|
||||
"classes": class_rows.count or 0,
|
||||
"pending_invitations": pending_invites.count or 0,
|
||||
},
|
||||
"terms": terms,
|
||||
"has_calendar": len(terms) > 0,
|
||||
}
|
||||
|
||||
|
||||
# ─── Calendar days (admin view) ───────────────────────────────────────────────
|
||||
|
||||
@router.get("/calendar/days")
|
||||
async def list_calendar_days(
|
||||
term_id: Optional[str] = None,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Return academic_days for the school, optionally filtered by term.
|
||||
Includes week_cycle from the parent academic_week.
|
||||
"""
|
||||
user_id = credentials.get("sub", "")
|
||||
sb = _get_sb()
|
||||
|
||||
p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
|
||||
school_id = str((p.data or {}).get("school_id") or "")
|
||||
if not school_id:
|
||||
raise HTTPException(status_code=400, detail="User is not linked to a school")
|
||||
|
||||
q = (
|
||||
sb.supabase.table("academic_days")
|
||||
.select("id,date,day_of_week,day_type,academic_week_id,academic_term_id,academic_day_number,excluded_period_codes")
|
||||
.eq("institute_id", school_id)
|
||||
.order("date")
|
||||
)
|
||||
if term_id:
|
||||
q = q.eq("academic_term_id", term_id)
|
||||
|
||||
days = q.execute().data or []
|
||||
|
||||
# Enrich with week_cycle
|
||||
if days:
|
||||
week_ids = list({d["academic_week_id"] for d in days if d.get("academic_week_id")})
|
||||
weeks = (
|
||||
sb.supabase.table("academic_weeks")
|
||||
.select("id,week_number,week_cycle")
|
||||
.in_("id", week_ids)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
wk_map = {w["id"]: w for w in weeks}
|
||||
for d in days:
|
||||
wk = wk_map.get(d.get("academic_week_id", ""), {})
|
||||
d["week_cycle"] = wk.get("week_cycle", "")
|
||||
d["week_number"] = wk.get("week_number")
|
||||
|
||||
return {"status": "ok", "days": days, "total": len(days)}
|
||||
|
||||
|
||||
@router.patch("/calendar/days/{day_id}")
|
||||
async def update_calendar_day(
|
||||
day_id: str,
|
||||
body: Dict[str, Any],
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Override day_type for a single academic day (school admin only).
|
||||
Syncs academic_periods: removes periods for non-Academic days,
|
||||
creates periods from periods_template for newly-Academic days.
|
||||
"""
|
||||
user_id = credentials.get("sub", "")
|
||||
sb = _get_sb()
|
||||
|
||||
p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
|
||||
school_id = str((p.data or {}).get("school_id") or "")
|
||||
if not school_id:
|
||||
raise HTTPException(status_code=400, detail="User is not linked to a school")
|
||||
|
||||
# Verify admin role
|
||||
mem = (
|
||||
sb.supabase.table("institute_memberships")
|
||||
.select("role")
|
||||
.eq("profile_id", user_id)
|
||||
.eq("institute_id", school_id)
|
||||
.single()
|
||||
.execute()
|
||||
)
|
||||
if (mem.data or {}).get("role") not in ("school_admin", "department_head"):
|
||||
raise HTTPException(status_code=403, detail="School admin access required")
|
||||
|
||||
# Verify day belongs to school
|
||||
day = (
|
||||
sb.supabase.table("academic_days")
|
||||
.select("*")
|
||||
.eq("id", day_id)
|
||||
.eq("institute_id", school_id)
|
||||
.single()
|
||||
.execute()
|
||||
).data
|
||||
if not day:
|
||||
raise HTTPException(status_code=404, detail="Day not found")
|
||||
|
||||
new_day_type = body.get("day_type", day["day_type"])
|
||||
excluded = body.get("excluded_period_codes", day.get("excluded_period_codes") or [])
|
||||
|
||||
valid_types = {"Academic", "Holiday", "Staff", "OffTimetable"}
|
||||
if new_day_type not in valid_types:
|
||||
raise HTTPException(status_code=400, detail=f"day_type must be one of {sorted(valid_types)}")
|
||||
|
||||
# Update the day
|
||||
sb.supabase.table("academic_days").update({
|
||||
"day_type": new_day_type,
|
||||
"excluded_period_codes": excluded,
|
||||
}).eq("id", day_id).execute()
|
||||
|
||||
old_type = day["day_type"]
|
||||
periods_changed = 0
|
||||
|
||||
if old_type == "Academic" and new_day_type != "Academic":
|
||||
# Remove periods for this day
|
||||
del_res = (
|
||||
sb.supabase.table("academic_periods")
|
||||
.delete()
|
||||
.eq("academic_day_id", day_id)
|
||||
.execute()
|
||||
)
|
||||
periods_changed = -(len(del_res.data or []))
|
||||
|
||||
elif old_type != "Academic" and new_day_type == "Academic":
|
||||
# Create periods from template
|
||||
stt = (
|
||||
sb.supabase.table("school_timetables")
|
||||
.select("periods_template")
|
||||
.eq("institute_id", school_id)
|
||||
.order("created_at", desc=True)
|
||||
.limit(1)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
template = (stt[0].get("periods_template") or []) if stt else []
|
||||
skip = set(excluded)
|
||||
new_periods = []
|
||||
for period in template:
|
||||
if period.get("code") in skip:
|
||||
continue
|
||||
new_periods.append({
|
||||
"academic_day_id": day_id,
|
||||
"institute_id": school_id,
|
||||
"period_code": period["code"],
|
||||
"period_name": period.get("name", period["code"]),
|
||||
"start_time": period.get("start_time"),
|
||||
"end_time": period.get("end_time"),
|
||||
"period_type": period.get("period_type", "lesson"),
|
||||
})
|
||||
if new_periods:
|
||||
ins_res = (
|
||||
sb.supabase.table("academic_periods")
|
||||
.upsert(new_periods, on_conflict="academic_day_id,period_code")
|
||||
.execute()
|
||||
)
|
||||
periods_changed = len(ins_res.data or [])
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"day_id": day_id,
|
||||
"new_day_type": new_day_type,
|
||||
"periods_changed": periods_changed,
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
"""
|
||||
Taught Lessons Router — materialization and lesson CRUD.
|
||||
|
||||
POST /materialize — slot template × academic_periods → taught_lessons rows
|
||||
GET /lessons — teacher's lessons for a date range
|
||||
GET /lessons/{id} — single lesson detail
|
||||
PATCH /lessons/{id} — update lesson_plan, notes, status (teacher-owned)
|
||||
"""
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
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()
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ─── Request models ───────────────────────────────────────────────────────────
|
||||
|
||||
class UpdateLessonRequest(BaseModel):
|
||||
lesson_plan: Optional[Dict[str, Any]] = None
|
||||
notes: Optional[str] = None
|
||||
status: Optional[str] = None # planned | in_progress | completed | cancelled | substituted
|
||||
|
||||
|
||||
# ─── Materialize ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/materialize")
|
||||
async def materialize_taught_lessons(
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Materialize taught_lessons from teacher_timetable_slots × academic_periods.
|
||||
|
||||
For each slot (day_of_week + period_code + week_cycle), find every
|
||||
academic_period that falls on a matching day and week cycle, then
|
||||
UPSERT a taught_lesson row and a whiteboard_room for it.
|
||||
|
||||
Safe to re-run; uses ON CONFLICT DO UPDATE.
|
||||
"""
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
sb = _sb()
|
||||
|
||||
# ── 1. Get teacher timetable ──────────────────────────────────────────────
|
||||
tt_rows = (
|
||||
sb.supabase.table("teacher_timetables")
|
||||
.select("id")
|
||||
.eq("profile_id", user_id)
|
||||
.eq("institute_id", institute_id)
|
||||
.limit(1)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
if not tt_rows:
|
||||
return {"status": "error", "message": "No teacher timetable found — run /timetable/init first"}
|
||||
tt_id = tt_rows[0]["id"]
|
||||
|
||||
# ── 2. Get teacher's timetable slots ──────────────────────────────────────
|
||||
slots = (
|
||||
sb.supabase.table("teacher_timetable_slots")
|
||||
.select("id,day_of_week,period_code,subject_class,start_time,end_time,week_cycle,class_id")
|
||||
.eq("teacher_timetable_id", tt_id)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
if not slots:
|
||||
return {"status": "ok", "created": 0, "updated": 0, "message": "No timetable slots found"}
|
||||
|
||||
# ── 3. Resolve class_ids for slots (match by subject_class name) ──────────
|
||||
class_name_to_id: Dict[str, str] = {}
|
||||
subject_names = list({s["subject_class"] for s in slots if s.get("subject_class")})
|
||||
if subject_names:
|
||||
try:
|
||||
classes_res = (
|
||||
sb.supabase.table("classes")
|
||||
.select("id,name,class_code")
|
||||
.eq("institute_id", institute_id)
|
||||
.eq("is_active", True)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
for c in classes_res:
|
||||
if c.get("name"):
|
||||
class_name_to_id[c["name"]] = c["id"]
|
||||
if c.get("class_code"):
|
||||
class_name_to_id[c["class_code"]] = c["id"]
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not resolve class names: {e}")
|
||||
|
||||
# slot lookup keyed by (day_of_week, period_code, week_cycle)
|
||||
# week_cycle='' means applies to both A and B weeks
|
||||
slot_map: Dict[Tuple[str, str, str], Dict] = {}
|
||||
for slot in slots:
|
||||
key = (slot["day_of_week"], slot["period_code"], slot.get("week_cycle", ""))
|
||||
slot_map[key] = slot
|
||||
|
||||
# ── 4. Get all academic_periods with day + week info ──────────────────────
|
||||
# Fetch academic_days and academic_weeks separately, then join in Python
|
||||
days_res = (
|
||||
sb.supabase.table("academic_days")
|
||||
.select("id,date,day_of_week,academic_week_id,day_type")
|
||||
.eq("institute_id", institute_id)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
weeks_res = (
|
||||
sb.supabase.table("academic_weeks")
|
||||
.select("id,week_cycle")
|
||||
.eq("institute_id", institute_id)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
week_cycle_map = {w["id"]: w["week_cycle"] for w in weeks_res}
|
||||
|
||||
# Only academic days get lesson periods
|
||||
academic_day_map = {
|
||||
d["id"]: {**d, "week_cycle": week_cycle_map.get(d["academic_week_id"], "A")}
|
||||
for d in days_res
|
||||
if d["day_type"] == "Academic"
|
||||
}
|
||||
|
||||
periods_res = (
|
||||
sb.supabase.table("academic_periods")
|
||||
.select("id,academic_day_id,period_code,period_name,start_time,end_time,period_type")
|
||||
.eq("institute_id", institute_id)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
|
||||
# ── 5. Match slots to periods ─────────────────────────────────────────────
|
||||
to_upsert: List[Dict] = []
|
||||
whiteboard_rows: List[Dict] = []
|
||||
|
||||
for period in periods_res:
|
||||
day_info = academic_day_map.get(period["academic_day_id"])
|
||||
if not day_info:
|
||||
continue
|
||||
|
||||
dow = day_info["day_of_week"]
|
||||
week_cycle = day_info["week_cycle"]
|
||||
pcode = period["period_code"]
|
||||
d = str(day_info["date"])[:10]
|
||||
|
||||
# Check all matching slot patterns: exact week_cycle match or '' (both weeks)
|
||||
matched_slot = (
|
||||
slot_map.get((dow, pcode, week_cycle))
|
||||
or slot_map.get((dow, pcode, ""))
|
||||
)
|
||||
if not matched_slot:
|
||||
continue
|
||||
|
||||
subj_class = matched_slot.get("subject_class", "")
|
||||
# Prefer the slot's own class_id, fall back to name-lookup
|
||||
class_id = (
|
||||
matched_slot.get("class_id")
|
||||
or class_name_to_id.get(subj_class)
|
||||
)
|
||||
|
||||
tl_id_hint = f"tl_{period['id']}" # deterministic for neo4j_node_id
|
||||
to_upsert.append({
|
||||
"academic_period_id": period["id"],
|
||||
"teacher_timetable_slot_id": matched_slot["id"],
|
||||
"class_id": class_id, # may be None
|
||||
"teacher_id": user_id,
|
||||
"institute_id": institute_id,
|
||||
"date": d,
|
||||
"period_code": pcode,
|
||||
"week_cycle": week_cycle,
|
||||
"day_of_week": dow,
|
||||
"status": "planned",
|
||||
"neo4j_node_id": tl_id_hint,
|
||||
})
|
||||
|
||||
if not to_upsert:
|
||||
return {"status": "ok", "created": 0, "updated": 0, "message": "No slot-period matches found"}
|
||||
|
||||
# ── 6. Batch-upsert taught_lessons ────────────────────────────────────────
|
||||
BATCH = 100
|
||||
created = 0
|
||||
for i in range(0, len(to_upsert), BATCH):
|
||||
chunk = to_upsert[i : i + BATCH]
|
||||
try:
|
||||
res = (
|
||||
sb.supabase.table("taught_lessons")
|
||||
.upsert(chunk, on_conflict="academic_period_id,teacher_id")
|
||||
.execute()
|
||||
)
|
||||
created += len(res.data or [])
|
||||
except Exception as e:
|
||||
logger.error(f"taught_lessons upsert chunk {i}: {e}")
|
||||
|
||||
# ── 7. Fetch newly-created taught_lesson ids, create whiteboard_rooms ─────
|
||||
try:
|
||||
tl_rows = (
|
||||
sb.supabase.table("taught_lessons")
|
||||
.select("id,date,period_code,class_id")
|
||||
.eq("teacher_id", user_id)
|
||||
.eq("institute_id", institute_id)
|
||||
.is_("whiteboard_room_id", "null")
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
|
||||
# Build whiteboard_room rows
|
||||
wr_rows = []
|
||||
for tl in tl_rows:
|
||||
class_name = class_name_to_id and next(
|
||||
(name for name, cid in class_name_to_id.items() if cid == tl.get("class_id")),
|
||||
tl.get("period_code", "")
|
||||
)
|
||||
wr_rows.append({
|
||||
"user_id": user_id,
|
||||
"institute_id": institute_id,
|
||||
"name": f"{tl['date']} {tl['period_code']}",
|
||||
"context_type": "taught_lesson",
|
||||
"context_id": tl["id"],
|
||||
"storage_path": f"taught_lessons/{tl['id']}",
|
||||
})
|
||||
|
||||
# Batch insert whiteboard_rooms
|
||||
wr_ids: Dict[str, str] = {} # context_id → room_id
|
||||
for i in range(0, len(wr_rows), BATCH):
|
||||
chunk = wr_rows[i : i + BATCH]
|
||||
try:
|
||||
res = sb.supabase.table("whiteboard_rooms").insert(chunk).execute()
|
||||
for row in (res.data or []):
|
||||
if row.get("context_id"):
|
||||
wr_ids[row["context_id"]] = row["id"]
|
||||
except Exception as e:
|
||||
logger.warning(f"whiteboard_rooms batch insert: {e}")
|
||||
|
||||
# Update taught_lessons with their whiteboard_room_id
|
||||
for context_id, room_id in wr_ids.items():
|
||||
try:
|
||||
sb.supabase.table("taught_lessons").update(
|
||||
{"whiteboard_room_id": room_id}
|
||||
).eq("id", context_id).execute()
|
||||
except Exception as e:
|
||||
logger.warning(f"whiteboard_room_id update for {context_id}: {e}")
|
||||
|
||||
rooms_created = len(wr_ids)
|
||||
except Exception as e:
|
||||
logger.warning(f"Whiteboard room creation failed (non-fatal): {e}")
|
||||
rooms_created = 0
|
||||
|
||||
logger.info(f"Materialized {created} taught_lessons, {rooms_created} whiteboard_rooms for teacher {user_id}")
|
||||
return {
|
||||
"status": "ok",
|
||||
"lessons_upserted": created,
|
||||
"whiteboard_rooms_created": rooms_created,
|
||||
"total_matches": len(to_upsert),
|
||||
}
|
||||
|
||||
|
||||
# ─── Lesson timeline ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/lessons")
|
||||
async def get_lessons(
|
||||
week_start: Optional[str] = None, # ISO date "YYYY-MM-DD" (Monday); defaults to current week
|
||||
weeks: int = 1, # how many weeks to return (max 4)
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Return taught_lessons for the teacher within a date range, grouped by date.
|
||||
Defaults to the current week.
|
||||
"""
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
sb = _sb()
|
||||
|
||||
# Resolve week window
|
||||
today = date.today()
|
||||
if week_start:
|
||||
try:
|
||||
monday = datetime.strptime(week_start, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
monday = today - timedelta(days=today.weekday())
|
||||
else:
|
||||
monday = today - timedelta(days=today.weekday())
|
||||
|
||||
weeks = min(max(weeks, 1), 4)
|
||||
friday = monday + timedelta(weeks=weeks, days=4)
|
||||
|
||||
lessons = (
|
||||
sb.supabase.table("taught_lessons")
|
||||
.select(
|
||||
"id,date,period_code,week_cycle,day_of_week,status,lesson_plan,notes,whiteboard_room_id,"
|
||||
"class_id,academic_period_id"
|
||||
)
|
||||
.eq("teacher_id", user_id)
|
||||
.eq("institute_id", institute_id)
|
||||
.gte("date", str(monday))
|
||||
.lte("date", str(friday))
|
||||
.order("date")
|
||||
.order("period_code")
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
|
||||
# Enrich with period time and class name
|
||||
period_ids = [l["academic_period_id"] for l in lessons if l.get("academic_period_id")]
|
||||
class_ids = list({l["class_id"] for l in lessons if l.get("class_id")})
|
||||
|
||||
period_map: Dict[str, Dict] = {}
|
||||
class_map: Dict[str, Dict] = {}
|
||||
|
||||
if period_ids:
|
||||
prows = (
|
||||
sb.supabase.table("academic_periods")
|
||||
.select("id,period_name,start_time,end_time")
|
||||
.in_("id", period_ids)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
period_map = {r["id"]: r for r in prows}
|
||||
|
||||
if class_ids:
|
||||
crows = (
|
||||
sb.supabase.table("classes")
|
||||
.select("id,name,class_code,subject,year_group")
|
||||
.in_("id", class_ids)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
class_map = {r["id"]: r for r in crows}
|
||||
|
||||
# Group by date
|
||||
days_map: Dict[str, List[Dict]] = defaultdict(list)
|
||||
for lesson in lessons:
|
||||
p = period_map.get(lesson.get("academic_period_id", ""), {})
|
||||
c = class_map.get(lesson.get("class_id", ""), {})
|
||||
enriched = {
|
||||
**lesson,
|
||||
"period_name": p.get("period_name", lesson["period_code"]),
|
||||
"start_time": p.get("start_time"),
|
||||
"end_time": p.get("end_time"),
|
||||
"class_name": c.get("name") or c.get("class_code"),
|
||||
"subject": c.get("subject"),
|
||||
"year_group": c.get("year_group"),
|
||||
}
|
||||
days_map[lesson["date"]].append(enriched)
|
||||
|
||||
# Build ordered list of days
|
||||
days_list = []
|
||||
current = monday
|
||||
while current <= friday:
|
||||
d_str = str(current)
|
||||
days_list.append({
|
||||
"date": d_str,
|
||||
"day_of_week": current.strftime("%A"),
|
||||
"is_today": current == today,
|
||||
"lessons": days_map.get(d_str, []),
|
||||
})
|
||||
current += timedelta(days=1)
|
||||
# Skip weekends
|
||||
if current.weekday() >= 5:
|
||||
current += timedelta(days=7 - current.weekday())
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"week_start": str(monday),
|
||||
"week_end": str(friday),
|
||||
"days": days_list,
|
||||
"total_lessons": len(lessons),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/lessons/{lesson_id}")
|
||||
async def get_lesson(
|
||||
lesson_id: str,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
sb = _sb()
|
||||
|
||||
res = (
|
||||
sb.supabase.table("taught_lessons")
|
||||
.select("*")
|
||||
.eq("id", lesson_id)
|
||||
.eq("teacher_id", user_id)
|
||||
.single()
|
||||
.execute()
|
||||
)
|
||||
if not res.data:
|
||||
raise HTTPException(status_code=404, detail="Lesson not found")
|
||||
return res.data
|
||||
|
||||
|
||||
@router.patch("/lessons/{lesson_id}")
|
||||
async def update_lesson(
|
||||
lesson_id: str,
|
||||
body: UpdateLessonRequest,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
"""Teacher updates their own lesson content: plan, notes, status."""
|
||||
user_id = credentials.get("sub", "")
|
||||
sb = _sb()
|
||||
|
||||
updates: Dict[str, Any] = {}
|
||||
if body.lesson_plan is not None:
|
||||
updates["lesson_plan"] = body.lesson_plan
|
||||
if body.notes is not None:
|
||||
updates["notes"] = body.notes
|
||||
if body.status is not None:
|
||||
valid = {"planned", "in_progress", "completed", "cancelled", "substituted"}
|
||||
if body.status not in valid:
|
||||
raise HTTPException(status_code=400, detail=f"status must be one of {valid}")
|
||||
updates["status"] = body.status
|
||||
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail="Nothing to update")
|
||||
|
||||
updates["updated_at"] = datetime.utcnow().isoformat()
|
||||
res = (
|
||||
sb.supabase.table("taught_lessons")
|
||||
.update(updates)
|
||||
.eq("id", lesson_id)
|
||||
.eq("teacher_id", user_id)
|
||||
.execute()
|
||||
)
|
||||
if not res.data:
|
||||
raise HTTPException(status_code=404, detail="Lesson not found or access denied")
|
||||
return res.data[0]
|
||||
|
||||
|
||||
# ─── Student lesson view ──────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/student/lessons")
|
||||
async def get_student_lessons(
|
||||
week_start: Optional[str] = None,
|
||||
weeks: int = 1,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Return taught_lessons for a student's enrolled classes for a date range.
|
||||
Grouped by date, Mon-Fri only.
|
||||
"""
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _resolve_institute_id(user_id)
|
||||
if not institute_id:
|
||||
return {"status": "error", "message": "Not linked to a school"}
|
||||
sb = _sb()
|
||||
|
||||
today = date.today()
|
||||
if week_start:
|
||||
try:
|
||||
monday = datetime.strptime(week_start, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
monday = today - timedelta(days=today.weekday())
|
||||
else:
|
||||
monday = today - timedelta(days=today.weekday())
|
||||
|
||||
weeks = min(max(weeks, 1), 4)
|
||||
friday = monday + timedelta(weeks=weeks, days=4)
|
||||
|
||||
# Get student's active class memberships
|
||||
memberships = (
|
||||
sb.supabase.table("class_students")
|
||||
.select("class_id")
|
||||
.eq("student_id", user_id)
|
||||
.eq("status", "active")
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
class_ids = [m["class_id"] for m in memberships]
|
||||
if not class_ids:
|
||||
days_list = []
|
||||
current = monday
|
||||
while current <= friday:
|
||||
days_list.append({
|
||||
"date": str(current),
|
||||
"day_of_week": current.strftime("%A"),
|
||||
"is_today": current == today,
|
||||
"lessons": [],
|
||||
})
|
||||
current += timedelta(days=1)
|
||||
if current.weekday() >= 5:
|
||||
current += timedelta(days=7 - current.weekday())
|
||||
return {"status": "ok", "week_start": str(monday), "days": days_list, "total_lessons": 0}
|
||||
|
||||
# Query taught_lessons for those classes
|
||||
lessons = (
|
||||
sb.supabase.table("taught_lessons")
|
||||
.select(
|
||||
"id,date,period_code,week_cycle,day_of_week,status,lesson_plan,notes,whiteboard_room_id,"
|
||||
"class_id,academic_period_id,teacher_id"
|
||||
)
|
||||
.in_("class_id", class_ids)
|
||||
.eq("institute_id", institute_id)
|
||||
.gte("date", str(monday))
|
||||
.lte("date", str(friday))
|
||||
.order("date")
|
||||
.order("period_code")
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
|
||||
# Enrich with period times, class name, teacher name
|
||||
period_ids = [l["academic_period_id"] for l in lessons if l.get("academic_period_id")]
|
||||
lesson_class_ids = list({l["class_id"] for l in lessons if l.get("class_id")})
|
||||
teacher_ids = list({l["teacher_id"] for l in lessons if l.get("teacher_id")})
|
||||
|
||||
period_map: Dict[str, Dict] = {}
|
||||
class_map: Dict[str, Dict] = {}
|
||||
teacher_map: Dict[str, Dict] = {}
|
||||
|
||||
if period_ids:
|
||||
prows = (
|
||||
sb.supabase.table("academic_periods")
|
||||
.select("id,period_name,start_time,end_time")
|
||||
.in_("id", period_ids)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
period_map = {r["id"]: r for r in prows}
|
||||
|
||||
if lesson_class_ids:
|
||||
crows = (
|
||||
sb.supabase.table("classes")
|
||||
.select("id,name,class_code,subject,year_group")
|
||||
.in_("id", lesson_class_ids)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
class_map = {r["id"]: r for r in crows}
|
||||
|
||||
if teacher_ids:
|
||||
trows = (
|
||||
sb.supabase.table("profiles")
|
||||
.select("id,full_name,display_name")
|
||||
.in_("id", teacher_ids)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
teacher_map = {r["id"]: r for r in trows}
|
||||
|
||||
from collections import defaultdict
|
||||
days_map: Dict[str, List[Dict]] = defaultdict(list)
|
||||
for lesson in lessons:
|
||||
p = period_map.get(lesson.get("academic_period_id", ""), {})
|
||||
c = class_map.get(lesson.get("class_id", ""), {})
|
||||
t = teacher_map.get(lesson.get("teacher_id", ""), {})
|
||||
enriched = {
|
||||
**lesson,
|
||||
"period_name": p.get("period_name", lesson["period_code"]),
|
||||
"start_time": p.get("start_time"),
|
||||
"end_time": p.get("end_time"),
|
||||
"class_name": c.get("name") or c.get("class_code"),
|
||||
"subject": c.get("subject"),
|
||||
"year_group": c.get("year_group"),
|
||||
"teacher_name": t.get("display_name") or t.get("full_name"),
|
||||
}
|
||||
days_map[lesson["date"]].append(enriched)
|
||||
|
||||
days_list = []
|
||||
current = monday
|
||||
while current <= friday:
|
||||
d_str = str(current)
|
||||
days_list.append({
|
||||
"date": d_str,
|
||||
"day_of_week": current.strftime("%A"),
|
||||
"is_today": current == today,
|
||||
"lessons": days_map.get(d_str, []),
|
||||
})
|
||||
current += timedelta(days=1)
|
||||
if current.weekday() >= 5:
|
||||
current += timedelta(days=7 - current.weekday())
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"week_start": str(monday),
|
||||
"week_end": str(friday),
|
||||
"days": days_list,
|
||||
"total_lessons": len(lessons),
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,15 +11,117 @@ load_dotenv(find_dotenv())
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from typing import Dict, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from typing import Dict, Any, Tuple
|
||||
|
||||
from modules.database.supabase.utils.storage import StorageAdmin
|
||||
from modules.auth.supabase_bearer import SupabaseBearer
|
||||
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
|
||||
from modules.database.supabase.utils.storage import StorageError, StorageUser
|
||||
from modules.logger_tool import initialise_logger
|
||||
|
||||
router = APIRouter()
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
|
||||
|
||||
ALLOWED_SNAPSHOT_BUCKETS = {"cc.public.snapshots"}
|
||||
PERSONAL_NODE_TYPES = {"User", "Teacher", "Developer", "SuperAdmin", "UserTeacherTimetable"}
|
||||
GLOBAL_READONLY_NODE_TYPES = {"CalendarYear", "CalendarMonth", "CalendarWeek", "CalendarDay", "CalendarTimeChunk"}
|
||||
|
||||
|
||||
def _sb() -> SupabaseServiceRoleClient:
|
||||
return SupabaseServiceRoleClient()
|
||||
|
||||
|
||||
def _parse_snapshot_path(path: str) -> Tuple[str, str, str, str]:
|
||||
"""Parse and validate bucket/node_type/node_id into a storage object path."""
|
||||
if not path:
|
||||
raise HTTPException(status_code=400, detail="Path not provided")
|
||||
path_parts = [part for part in path.split('/') if part]
|
||||
if len(path_parts) != 3:
|
||||
raise HTTPException(status_code=400, detail="Invalid path format. Expected: bucket/nodetype/node_id")
|
||||
|
||||
bucket, node_type, node_id = path_parts
|
||||
if bucket not in ALLOWED_SNAPSHOT_BUCKETS:
|
||||
raise HTTPException(status_code=403, detail="Snapshot bucket is not allowed")
|
||||
if any(part in {".", ".."} or ".." in part for part in path_parts):
|
||||
raise HTTPException(status_code=400, detail="Invalid path component")
|
||||
if not node_type.replace("_", "").replace("-", "").isalnum():
|
||||
raise HTTPException(status_code=400, detail="Invalid node type")
|
||||
if not node_id.replace("_", "").replace("-", "").isalnum():
|
||||
raise HTTPException(status_code=400, detail="Invalid node id")
|
||||
|
||||
return bucket, node_type, node_id, f"{node_type}/{node_id}/tldraw_file.json"
|
||||
|
||||
|
||||
def _user_scope(user_id: str) -> Dict[str, Any]:
|
||||
"""Resolve Supabase/Neo4j scope for the authenticated user."""
|
||||
scope: Dict[str, Any] = {
|
||||
"user_id": user_id,
|
||||
"teacher_db": f"cc.users.teacher.{user_id.replace('-', '')}" if user_id else "",
|
||||
"institute_id": "",
|
||||
"institute_db": "",
|
||||
"curriculum_db": "",
|
||||
}
|
||||
if not user_id:
|
||||
return scope
|
||||
try:
|
||||
sb = _sb()
|
||||
prof = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
|
||||
school_id = str((prof.data or {}).get("school_id") or "")
|
||||
scope["institute_id"] = school_id
|
||||
if school_id:
|
||||
inst = sb.supabase.table("institutes").select("neo4j_uuid_string").eq("id", school_id).single().execute()
|
||||
neo4j_uuid = (inst.data or {}).get("neo4j_uuid_string")
|
||||
if neo4j_uuid:
|
||||
scope["institute_db"] = f"cc.institutes.{neo4j_uuid}"
|
||||
scope["curriculum_db"] = f"cc.institutes.{neo4j_uuid}.curriculum"
|
||||
except Exception as exc:
|
||||
logger.warning(f"Could not resolve TLDraw storage scope for user {user_id}: {exc}")
|
||||
return scope
|
||||
|
||||
|
||||
def _authorize_snapshot_path(path: str, db_name: str, credentials: Dict[str, Any], write: bool) -> Tuple[str, str, str, str]:
|
||||
"""Authorize TLDraw snapshot access before touching Supabase Storage."""
|
||||
user_id = credentials.get("sub", "")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=403, detail="Could not extract user_id from token")
|
||||
bucket, node_type, node_id, file_path = _parse_snapshot_path(path)
|
||||
scope = _user_scope(user_id)
|
||||
allowed_dbs = {db for db in (scope["teacher_db"], scope["institute_db"], scope["curriculum_db"]) if db}
|
||||
|
||||
if node_type in PERSONAL_NODE_TYPES:
|
||||
if node_id == user_id or (db_name and db_name == scope["teacher_db"]):
|
||||
return bucket, node_type, node_id, file_path
|
||||
raise HTTPException(status_code=403, detail="Snapshot path is outside the authenticated user's workspace")
|
||||
|
||||
if node_type in GLOBAL_READONLY_NODE_TYPES and db_name == "classroomcopilot":
|
||||
if write:
|
||||
raise HTTPException(status_code=403, detail="Global calendar snapshots are read-only")
|
||||
return bucket, node_type, node_id, file_path
|
||||
|
||||
# Institute/curriculum snapshots must be accessed through the caller's institute DB.
|
||||
if db_name and db_name in allowed_dbs:
|
||||
return bucket, node_type, node_id, file_path
|
||||
|
||||
raise HTTPException(status_code=403, detail="Snapshot path is outside the authenticated user's tenant")
|
||||
|
||||
|
||||
def _storage_for_user(credentials: Dict[str, Any]) -> StorageUser:
|
||||
access_token = credentials.get("_access_token")
|
||||
if not access_token:
|
||||
raise HTTPException(status_code=403, detail="User access token is required for storage access")
|
||||
return StorageUser(user_id=credentials.get("sub"), access_token=access_token)
|
||||
|
||||
|
||||
def _is_valid_tldraw_snapshot(snapshot_data: Any) -> bool:
|
||||
if not isinstance(snapshot_data, dict):
|
||||
return False
|
||||
if not ("document" in snapshot_data and "session" in snapshot_data):
|
||||
return False
|
||||
document = snapshot_data.get("document")
|
||||
if isinstance(document, dict) and "schema" in document:
|
||||
return True
|
||||
return "schemaVersion" in snapshot_data
|
||||
|
||||
def create_default_tldraw_content():
|
||||
"""Create default tldraw content structure."""
|
||||
return {
|
||||
@@ -101,7 +203,8 @@ def create_default_tldraw_content():
|
||||
@router.get("/get_tldraw_node_file")
|
||||
async def read_tldraw_node_file_from_supabase(
|
||||
path: str = Query(..., description="Supabase Storage path (e.g., 'cc.public.snapshots/User/user_id')"),
|
||||
db_name: str = Query(..., description="Database name for context")
|
||||
db_name: str = Query(..., description="Database name for context"),
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
):
|
||||
"""
|
||||
Load TLDraw snapshot from Supabase Storage.
|
||||
@@ -116,26 +219,9 @@ async def read_tldraw_node_file_from_supabase(
|
||||
logger.debug(f"Reading tldraw file from Supabase Storage for path: {path}")
|
||||
logger.debug(f"Database name: {db_name}")
|
||||
|
||||
if not path:
|
||||
raise HTTPException(status_code=400, detail="Path not provided")
|
||||
|
||||
try:
|
||||
# Initialize Supabase Storage
|
||||
storage = StorageAdmin()
|
||||
|
||||
# Parse the path to extract bucket and file path
|
||||
# Expected format: "cc.public.snapshots/User/user_id" or "cc.public.snapshots/Teacher/teacher_id"
|
||||
path_parts = path.split('/')
|
||||
if len(path_parts) < 3:
|
||||
raise HTTPException(status_code=400, detail="Invalid path format. Expected: bucket/nodetype/node_id")
|
||||
|
||||
bucket = path_parts[0] # e.g., "cc.public.snapshots"
|
||||
node_type = path_parts[1] # e.g., "User", "Teacher"
|
||||
node_id = path_parts[2] # e.g., "cbc309e5-4029-4c34-aab7-0aa33c563cd0"
|
||||
|
||||
# Construct the file path in Supabase Storage
|
||||
# Format: nodetype/node_id/tldraw_file.json
|
||||
file_path = f"{node_type}/{node_id}/tldraw_file.json"
|
||||
bucket, node_type, node_id, file_path = _authorize_snapshot_path(path, db_name, credentials, write=False)
|
||||
storage = _storage_for_user(credentials)
|
||||
|
||||
logger.debug(f"Bucket: {bucket}")
|
||||
logger.debug(f"File path: {file_path}")
|
||||
@@ -147,29 +233,17 @@ async def read_tldraw_node_file_from_supabase(
|
||||
# Parse JSON data
|
||||
try:
|
||||
snapshot_data = json.loads(file_data.decode('utf-8'))
|
||||
logger.info(f"Successfully loaded tldraw snapshot from Supabase Storage: {file_path}")
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as e:
|
||||
logger.warning(f"Malformed TLDraw snapshot {file_path}; returning default content: {e}")
|
||||
return create_default_tldraw_content()
|
||||
|
||||
logger.info(f"Successfully loaded tldraw snapshot from Supabase Storage: {file_path}")
|
||||
if _is_valid_tldraw_snapshot(snapshot_data):
|
||||
return snapshot_data
|
||||
logger.warning(f"Snapshot data from {file_path} is missing required TLDraw structure. Using default structure.")
|
||||
return create_default_tldraw_content()
|
||||
|
||||
# Ensure the snapshot has the correct structure for TLDraw
|
||||
if isinstance(snapshot_data, dict) and 'document' in snapshot_data and 'session' in snapshot_data:
|
||||
# Check if it has the new format (schemaVersion in document.schema)
|
||||
if 'document' in snapshot_data and isinstance(snapshot_data['document'], dict) and 'schema' in snapshot_data['document']:
|
||||
return snapshot_data
|
||||
# Check if it has the old format (schemaVersion at root level)
|
||||
elif 'schemaVersion' in snapshot_data:
|
||||
return snapshot_data
|
||||
else:
|
||||
# Use default structure if schema is missing
|
||||
logger.warning(f"Snapshot data from {file_path_in_bucket} is missing schemaVersion. Using default structure.")
|
||||
return create_default_tldraw_content()
|
||||
else:
|
||||
# Use default structure if basic structure is missing
|
||||
logger.warning(f"Snapshot data from {file_path_in_bucket} is missing top-level TLDraw keys. Using default structure.")
|
||||
return create_default_tldraw_content()
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse JSON from Supabase Storage file: {e}")
|
||||
raise HTTPException(status_code=500, detail="Invalid JSON in file")
|
||||
|
||||
except Exception as e:
|
||||
except StorageError as e:
|
||||
# File doesn't exist, create default content
|
||||
logger.info(f"File not found in Supabase Storage, creating default tldraw content: {file_path}")
|
||||
|
||||
@@ -199,7 +273,8 @@ async def read_tldraw_node_file_from_supabase(
|
||||
async def set_tldraw_node_file_in_supabase(
|
||||
path: str = Query(..., description="Supabase Storage path (e.g., 'cc.public.snapshots/User/user_id')"),
|
||||
db_name: str = Query(..., description="Database name for context"),
|
||||
data: Dict[str, Any] = None
|
||||
data: Dict[str, Any] = None,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
):
|
||||
"""
|
||||
Save TLDraw snapshot to Supabase Storage.
|
||||
@@ -215,27 +290,12 @@ async def set_tldraw_node_file_in_supabase(
|
||||
logger.debug(f"Saving tldraw file to Supabase Storage for path: {path}")
|
||||
logger.debug(f"Database name: {db_name}")
|
||||
|
||||
if not path:
|
||||
raise HTTPException(status_code=400, detail="Path not provided")
|
||||
|
||||
if not data:
|
||||
raise HTTPException(status_code=400, detail="Data not provided")
|
||||
|
||||
try:
|
||||
# Initialize Supabase Storage
|
||||
storage = StorageAdmin()
|
||||
|
||||
# Parse the path to extract bucket and file path
|
||||
path_parts = path.split('/')
|
||||
if len(path_parts) < 3:
|
||||
raise HTTPException(status_code=400, detail="Invalid path format. Expected: bucket/nodetype/node_id")
|
||||
|
||||
bucket = path_parts[0] # e.g., "cc.public.snapshots"
|
||||
node_type = path_parts[1] # e.g., "User", "Teacher"
|
||||
node_id = path_parts[2] # e.g., "cbc309e5-4029-4c34-aab7-0aa33c563cd0"
|
||||
|
||||
# Construct the file path in Supabase Storage
|
||||
file_path = f"{node_type}/{node_id}/tldraw_file.json"
|
||||
bucket, node_type, node_id, file_path = _authorize_snapshot_path(path, db_name, credentials, write=True)
|
||||
storage = _storage_for_user(credentials)
|
||||
|
||||
logger.debug(f"Bucket: {bucket}")
|
||||
logger.debug(f"File path: {file_path}")
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import os
|
||||
from typing import Dict, Any
|
||||
from fastapi import APIRouter, Depends
|
||||
from modules.logger_tool import initialise_logger
|
||||
from modules.auth.supabase_bearer import SupabaseBearer
|
||||
from modules.database.services.provisioning_service import ProvisioningService
|
||||
import modules.database.tools.neo4j_driver_tools as driver_tools
|
||||
import modules.database.schemas.nodes.users as user_nodes
|
||||
import modules.database.tools.neontology_tools as neon
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _teacher_db(user_id: str) -> str:
|
||||
return f"cc.users.teacher.{user_id.replace('-', '')}"
|
||||
|
||||
|
||||
def _ensure_journal_planner(user_id: str, teacher_db: str) -> None:
|
||||
neon.init_neontology_connection()
|
||||
try:
|
||||
with driver_tools.get_session(database=teacher_db) as session:
|
||||
has_journal = session.run("MATCH (j:Journal) RETURN count(j) AS n").single()["n"] > 0
|
||||
has_planner = session.run("MATCH (p:Planner) RETURN count(p) AS n").single()["n"] > 0
|
||||
|
||||
if not has_journal:
|
||||
journal = user_nodes.JournalNode(
|
||||
uuid_string=f"{user_id}_journal",
|
||||
node_storage_path=f"users/{user_id}/nodes/journal",
|
||||
user_id=user_id,
|
||||
)
|
||||
neon.create_or_merge_neontology_node(journal, database=teacher_db, operation='merge')
|
||||
logger.info(f"Created Journal node for {user_id}")
|
||||
|
||||
if not has_planner:
|
||||
planner = user_nodes.PlannerNode(
|
||||
uuid_string=f"{user_id}_planner",
|
||||
node_storage_path=f"users/{user_id}/nodes/planner",
|
||||
user_id=user_id,
|
||||
)
|
||||
neon.create_or_merge_neontology_node(planner, database=teacher_db, operation='merge')
|
||||
logger.info(f"Created Planner node for {user_id}")
|
||||
finally:
|
||||
neon.close_neontology_connection()
|
||||
|
||||
|
||||
@router.post("/init")
|
||||
async def init_user(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
if not user_id:
|
||||
return {"status": "error", "message": "No user ID in token"}
|
||||
|
||||
db = _teacher_db(user_id)
|
||||
|
||||
# Fast path: check if already fully provisioned
|
||||
try:
|
||||
with driver_tools.get_session(database=db) as session:
|
||||
r = session.run(
|
||||
"MATCH (u:User) "
|
||||
"OPTIONAL MATCH (j:Journal) "
|
||||
"OPTIONAL MATCH (p:Planner) "
|
||||
"RETURN count(u) AS u, count(j) AS j, count(p) AS p"
|
||||
).single()
|
||||
if r and r["u"] > 0 and r["j"] > 0 and r["p"] > 0:
|
||||
logger.debug(f"User {user_id} already initialized — fast path")
|
||||
return {"status": "ok", "initialized": True, "teacher_db": db}
|
||||
except Exception:
|
||||
pass # DB doesn't exist yet — fall through to full provisioning
|
||||
|
||||
# Full provisioning
|
||||
try:
|
||||
logger.info(f"Provisioning user {user_id}...")
|
||||
service = ProvisioningService()
|
||||
result = service.ensure_user(user_id)
|
||||
user_db = result.get("user_db_name") or db
|
||||
_ensure_journal_planner(user_id, user_db)
|
||||
logger.info(f"User {user_id} provisioned successfully: {user_db}")
|
||||
return {"status": "ok", "initialized": True, "teacher_db": user_db}
|
||||
except Exception as e:
|
||||
logger.error(f"User init failed for {user_id}: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Exam-marker API package (/api/exam/).
|
||||
|
||||
A clean top-level router group (R5.1/E5), deliberately NOT nested under /database/. Every
|
||||
endpoint authenticates the JWT and calls Supabase as-the-user so the RLS in
|
||||
volumes/db/cc/72-exam-marker.sql is enforced (spec E1/E2 fixes).
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
|
||||
from routers.exam.templates import router as templates_router
|
||||
from routers.exam.batches import router as batches_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(templates_router)
|
||||
router.include_router(batches_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,362 @@
|
||||
"""Marking batches, scans, marks, results & CSV (/api/exam/batches..., /api/exam/marks/...) — S4-6.
|
||||
|
||||
As with templates, all user-facing access is as-the-user (RLS-enforced; E1). A batch is owned by
|
||||
the teacher who creates it (R2.4); colleagues in the same institute can read it
|
||||
(marking_batches_read), a teacher in another institute cannot (→ 404, IDOR-safe).
|
||||
|
||||
Roster→cohort (R4.3/A7): creating a batch from a class materialises one student_submissions row
|
||||
per active enrollee (status='absent'), so every enrolled student is present in results/CSV from
|
||||
the start and a no-show is never silently dropped. The roster ids are read AS THE USER from
|
||||
class_students (cs_read requires the caller to teach/admin the class); only the display names are
|
||||
resolved via service role (profiles is deny-all as-user, E4 — see resolve_student_names).
|
||||
|
||||
Scans (R2.3/E3): the upload endpoint enforces a max size and validates that the bytes are a PDF
|
||||
before storing. QR-decode + automatic student-matching is a follow-on (no QR'd fixtures exist
|
||||
until the PrintGenerator card); v1 supports explicit (manual) and ordered matching.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import Response
|
||||
|
||||
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
|
||||
from modules.database.supabase.utils.storage import StorageAdmin
|
||||
from modules.logger_tool import initialise_logger
|
||||
from routers.exam.dependencies import ExamContext, get_exam_context, resolve_student_names
|
||||
from routers.exam.schemas import CreateBatchRequest, MarkUpsertRequest
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# E3: bound the upload so a 36-page scan batch can't exhaust memory / be a DoS vector.
|
||||
MAX_SCAN_BYTES = int(os.getenv("EXAM_SCAN_MAX_BYTES", str(50 * 1024 * 1024))) # 50 MB default
|
||||
SCANS_BUCKET = os.getenv("EXAM_SCANS_BUCKET", "cc.users")
|
||||
SCANS_PREFIX = "exam-submissions"
|
||||
|
||||
|
||||
# ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _rows(result: Any) -> List[Dict[str, Any]]:
|
||||
data = getattr(result, "data", None)
|
||||
if not data:
|
||||
return []
|
||||
return data if isinstance(data, list) else [data]
|
||||
|
||||
|
||||
def _first(result: Any) -> Optional[Dict[str, Any]]:
|
||||
rows = _rows(result)
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def _fetch_batch_or_404(ctx: ExamContext, batch_id: str) -> Dict[str, Any]:
|
||||
row = _first(ctx.supabase.table("marking_batches").select("*").eq("id", batch_id).limit(1).execute())
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Batch not found")
|
||||
return row
|
||||
|
||||
|
||||
def _require_owner(ctx: ExamContext, batch: Dict[str, Any]) -> None:
|
||||
if batch.get("teacher_id") != ctx.user_id:
|
||||
raise HTTPException(status_code=403, detail="Only the batch owner can modify it")
|
||||
|
||||
|
||||
# ─── batches ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/batches")
|
||||
async def create_batch(
|
||||
body: CreateBatchRequest,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
# The batch inherits the template's institute; reading the template as-user also proves the
|
||||
# caller may see it (RLS) — an unseeable template → 404.
|
||||
template = _first(
|
||||
ctx.supabase.table("exam_templates").select("id, institute_id").eq("id", body.template_id).limit(1).execute()
|
||||
)
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
batch_row = {
|
||||
"template_id": body.template_id,
|
||||
"class_id": body.class_id,
|
||||
"institute_id": template["institute_id"],
|
||||
"teacher_id": ctx.user_id,
|
||||
"title": body.title,
|
||||
"status": "open",
|
||||
}
|
||||
batch_row = {k: v for k, v in batch_row.items() if v is not None}
|
||||
batch = _first(ctx.supabase.table("marking_batches").insert(batch_row).execute())
|
||||
if not batch:
|
||||
raise HTTPException(status_code=500, detail="Failed to create batch")
|
||||
batch_id = batch["id"]
|
||||
|
||||
seeded = 0
|
||||
if body.class_id:
|
||||
# Roster read is AS THE USER → cs_read requires the caller to teach/admin the class.
|
||||
roster = _rows(
|
||||
ctx.supabase.table("class_students")
|
||||
.select("student_id")
|
||||
.eq("class_id", body.class_id)
|
||||
.eq("status", "active")
|
||||
.execute()
|
||||
)
|
||||
student_ids = [r["student_id"] for r in roster if r.get("student_id")]
|
||||
names = resolve_student_names(student_ids)
|
||||
if student_ids:
|
||||
sub_rows = [
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"student_id": sid,
|
||||
"student_name": names.get(sid),
|
||||
"status": "absent", # A7: present in results until a scan is matched
|
||||
}
|
||||
for sid in student_ids
|
||||
]
|
||||
ctx.supabase.table("student_submissions").insert(sub_rows).execute()
|
||||
seeded = len(sub_rows)
|
||||
|
||||
logger.info(f"Marking batch {batch_id} created by {ctx.user_id}; {seeded} roster submissions seeded")
|
||||
return {**batch, "submission_count": seeded}
|
||||
|
||||
|
||||
@router.get("/batches")
|
||||
async def list_batches(
|
||||
include_archived: bool = False,
|
||||
template_id: Optional[str] = None,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
q = ctx.supabase.table("marking_batches").select("*")
|
||||
if template_id:
|
||||
q = q.eq("template_id", template_id)
|
||||
if not include_archived:
|
||||
q = q.neq("status", "archived")
|
||||
return {"batches": _rows(q.order("created_at", desc=True).execute())}
|
||||
|
||||
|
||||
@router.get("/batches/{batch_id}/queue")
|
||||
async def batch_queue(
|
||||
batch_id: str,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
batch = _fetch_batch_or_404(ctx, batch_id)
|
||||
submissions = _rows(
|
||||
ctx.supabase.table("student_submissions").select("*").eq("batch_id", batch_id).execute()
|
||||
)
|
||||
marks = _rows(ctx.supabase.table("mark_entries").select("submission_id").eq("batch_id", batch_id).execute())
|
||||
marked_counts: Dict[str, int] = {}
|
||||
for m in marks:
|
||||
sid = m.get("submission_id")
|
||||
marked_counts[sid] = marked_counts.get(sid, 0) + 1
|
||||
|
||||
enriched = [{**s, "mark_entry_count": marked_counts.get(s["id"], 0)} for s in submissions]
|
||||
progress = {
|
||||
"total": len(submissions),
|
||||
"absent": sum(1 for s in submissions if s.get("status") == "absent"),
|
||||
"complete": sum(1 for s in submissions if s.get("status") == "complete"),
|
||||
"in_progress": sum(1 for s in submissions if s.get("status") in ("matched", "marking")),
|
||||
}
|
||||
return {"batch": batch, "submissions": enriched, "progress": progress}
|
||||
|
||||
|
||||
# ─── results & CSV (A7) ──────────────────────────────────────────────────────
|
||||
|
||||
def _assemble_results(ctx: ExamContext, batch: Dict[str, Any]) -> Dict[str, Any]:
|
||||
batch_id = batch["id"]
|
||||
questions = _rows(
|
||||
ctx.supabase.table("exam_questions")
|
||||
.select("id, label, max_marks, order")
|
||||
.eq("template_id", batch["template_id"])
|
||||
.order("order")
|
||||
.execute()
|
||||
)
|
||||
submissions = _rows(
|
||||
ctx.supabase.table("student_submissions").select("*").eq("batch_id", batch_id).execute()
|
||||
)
|
||||
marks = _rows(ctx.supabase.table("mark_entries").select("*").eq("batch_id", batch_id).execute())
|
||||
|
||||
by_sub: Dict[str, Dict[str, float]] = {}
|
||||
for m in marks:
|
||||
by_sub.setdefault(m["submission_id"], {})[m["question_id"]] = m.get("awarded_marks")
|
||||
|
||||
results = []
|
||||
for s in submissions: # every submission incl. absent → A7
|
||||
sub_marks = by_sub.get(s["id"], {})
|
||||
# Blank total ONLY for a genuine no-show (absent AND nothing marked). A student with any
|
||||
# mark gets a real total regardless of status; a present-but-unmarked student totals 0.
|
||||
if sub_marks:
|
||||
total = sum(v or 0 for v in sub_marks.values())
|
||||
elif s.get("status") == "absent":
|
||||
total = None
|
||||
else:
|
||||
total = 0
|
||||
results.append({
|
||||
"submission_id": s["id"],
|
||||
"student_id": s.get("student_id"),
|
||||
"student_name": s.get("student_name"),
|
||||
"status": s.get("status"),
|
||||
"marks": {qid: sub_marks.get(qid) for qid in (q["id"] for q in questions)},
|
||||
"total": total,
|
||||
})
|
||||
return {"batch": batch, "questions": questions, "results": results}
|
||||
|
||||
|
||||
@router.get("/batches/{batch_id}/results")
|
||||
async def batch_results(
|
||||
batch_id: str,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
batch = _fetch_batch_or_404(ctx, batch_id)
|
||||
return _assemble_results(ctx, batch)
|
||||
|
||||
|
||||
@router.get("/batches/{batch_id}/csv")
|
||||
async def batch_csv(
|
||||
batch_id: str,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Response:
|
||||
batch = _fetch_batch_or_404(ctx, batch_id)
|
||||
data = _assemble_results(ctx, batch)
|
||||
questions = data["questions"]
|
||||
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(["student_name", "student_id", "status"] + [q["label"] for q in questions] + ["total"])
|
||||
for r in data["results"]:
|
||||
# Absent students: blank marks + blank total, but the row is ALWAYS present (A7).
|
||||
cells = [
|
||||
"" if r["marks"].get(q["id"]) is None else r["marks"].get(q["id"])
|
||||
for q in questions
|
||||
]
|
||||
total = "" if r["total"] is None else r["total"]
|
||||
writer.writerow([r.get("student_name") or "", r.get("student_id") or "", r.get("status")] + cells + [total])
|
||||
|
||||
return Response(
|
||||
content=buf.getvalue(),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f'attachment; filename="batch-{batch_id}.csv"'},
|
||||
)
|
||||
|
||||
|
||||
# ─── marks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.put("/marks/{mark_id}")
|
||||
async def upsert_mark(
|
||||
mark_id: str,
|
||||
body: MarkUpsertRequest,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
# Derive batch_id from the submission (as-user read → also enforces the caller owns the batch
|
||||
# the submission belongs to). The client never supplies the RLS scoping key directly.
|
||||
submission = _first(
|
||||
ctx.supabase.table("student_submissions").select("id, batch_id, status").eq("id", body.submission_id).limit(1).execute()
|
||||
)
|
||||
if not submission:
|
||||
raise HTTPException(status_code=404, detail="Submission not found")
|
||||
|
||||
row = {
|
||||
"id": mark_id,
|
||||
"submission_id": body.submission_id,
|
||||
"question_id": body.question_id,
|
||||
"batch_id": submission["batch_id"],
|
||||
"awarded_marks": body.awarded_marks,
|
||||
"marked_by": "teacher",
|
||||
}
|
||||
if body.mark_scheme_detail is not None:
|
||||
row["mark_scheme_detail"] = body.mark_scheme_detail
|
||||
if body.annotation_shape_ids is not None:
|
||||
row["annotation_shape_ids"] = body.annotation_shape_ids
|
||||
if body.comment is not None:
|
||||
row["comment"] = body.comment
|
||||
if body.confirmed is not None:
|
||||
row["confirmed"] = body.confirmed
|
||||
|
||||
upserted = _first(ctx.supabase.table("mark_entries").upsert(row).execute())
|
||||
if not upserted:
|
||||
raise HTTPException(status_code=500, detail="Failed to upsert mark")
|
||||
|
||||
# A marked student is, by definition, not absent — advance the submission out of the
|
||||
# no-submission states so results/queue reflect that marking has started.
|
||||
if submission.get("status") in ("absent", "unmatched"):
|
||||
ctx.supabase.table("student_submissions").update({"status": "marking"}).eq("id", body.submission_id).execute()
|
||||
|
||||
return upserted
|
||||
|
||||
|
||||
# ─── scans (R2.3 / E3) ───────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/batches/{batch_id}/scans")
|
||||
async def upload_scan(
|
||||
batch_id: str,
|
||||
file: UploadFile = File(...),
|
||||
student_id: Optional[str] = Form(default=None),
|
||||
matching_method: str = Form(default="manual"),
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
batch = _fetch_batch_or_404(ctx, batch_id)
|
||||
_require_owner(ctx, batch)
|
||||
|
||||
# E3: validate MIME (client-declared) before reading the body.
|
||||
if (file.content_type or "").lower() not in ("application/pdf", "application/x-pdf"):
|
||||
raise HTTPException(status_code=415, detail="Only application/pdf scans are accepted")
|
||||
|
||||
# E3: read with a hard size ceiling instead of buffering an unbounded upload.
|
||||
chunks: List[bytes] = []
|
||||
total = 0
|
||||
while True:
|
||||
chunk = await file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > MAX_SCAN_BYTES:
|
||||
raise HTTPException(status_code=413, detail=f"Scan exceeds max size ({MAX_SCAN_BYTES} bytes)")
|
||||
chunks.append(chunk)
|
||||
data = b"".join(chunks)
|
||||
# E3: content-sniff — declared type can be spoofed; require the PDF magic header.
|
||||
if not data.startswith(b"%PDF-"):
|
||||
raise HTTPException(status_code=415, detail="Uploaded file is not a valid PDF")
|
||||
|
||||
# Store via service role (documented): no submissions-bucket storage RLS exists yet; the
|
||||
# endpoint already authorised the caller as the batch owner above.
|
||||
storage_path = f"{SCANS_PREFIX}/{batch_id}/{uuid.uuid4()}.pdf"
|
||||
try:
|
||||
StorageAdmin().upload_file(SCANS_BUCKET, storage_path, data, "application/pdf", upsert=True)
|
||||
except Exception as exc:
|
||||
logger.error(f"scan storage upload failed (batch={batch_id}): {exc}")
|
||||
raise HTTPException(status_code=502, detail="Failed to store scan")
|
||||
|
||||
sb = ctx.supabase
|
||||
submission: Optional[Dict[str, Any]] = None
|
||||
if matching_method == "manual" and student_id:
|
||||
submission = _first(
|
||||
sb.table("student_submissions").select("*").eq("batch_id", batch_id).eq("student_id", student_id).limit(1).execute()
|
||||
)
|
||||
elif matching_method == "ordered":
|
||||
# Assign to the next not-yet-submitted roster slot.
|
||||
pending = _rows(
|
||||
sb.table("student_submissions").select("*").eq("batch_id", batch_id).in_("status", ["absent", "unmatched"]).execute()
|
||||
)
|
||||
submission = pending[0] if pending else None
|
||||
|
||||
payload = {
|
||||
"scan_url": storage_path,
|
||||
"qr_code": None,
|
||||
"matching_method": matching_method if (student_id or matching_method == "ordered") else "manual",
|
||||
"page_count": None,
|
||||
"status": "matched" if submission else "unmatched",
|
||||
}
|
||||
|
||||
if submission:
|
||||
updated = _first(sb.table("student_submissions").update(payload).eq("id", submission["id"]).execute())
|
||||
return updated or submission
|
||||
# No roster slot matched → create an unmatched submission to be reconciled later.
|
||||
new_row = {"batch_id": batch_id, **payload}
|
||||
created = _first(sb.table("student_submissions").insert(new_row).execute())
|
||||
if not created:
|
||||
raise HTTPException(status_code=500, detail="Failed to record scan submission")
|
||||
return created
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Auth + data-access plumbing for the /api/exam/ router.
|
||||
|
||||
Per the audit (spec S1/E1): the exam API calls Supabase **as the user** so the RLS in
|
||||
72-exam-marker.sql is actually enforced — it does NOT use the service role for user-facing
|
||||
reads/writes the way files.py / classes_router.py do. The bearer already attaches the raw
|
||||
JWT as payload["_access_token"] (supabase_bearer.py) precisely for this.
|
||||
|
||||
Institute resolution is the one wrinkle: institute_memberships and profiles are RLS
|
||||
deny-all to a normal authenticated user (E4), so we cannot read them as-user. Instead we
|
||||
call public.user_institute_ids() — a SECURITY DEFINER function (71-class-management.sql) that
|
||||
PostgREST exposes as an RPC — which returns the caller's institute ids regardless of those
|
||||
table policies. This is the same function the RLS policies themselves key off, so the API's
|
||||
view of "which institutes is this user in" is guaranteed consistent with what RLS will allow.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
|
||||
from modules.auth.supabase_bearer import SupabaseBearer
|
||||
from modules.database.supabase.utils.client import (
|
||||
SupabaseAnonClient,
|
||||
SupabaseServiceRoleClient,
|
||||
)
|
||||
from modules.logger_tool import initialise_logger
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
|
||||
|
||||
auth = SupabaseBearer()
|
||||
|
||||
|
||||
class ExamContext:
|
||||
"""The per-request handle every exam endpoint works through.
|
||||
|
||||
Bundles the caller's id, an as-user Supabase client (RLS-enforced), and the set of
|
||||
institute ids the caller belongs to (for R5.5 institute validation on writes).
|
||||
"""
|
||||
|
||||
def __init__(self, user_id: str, access_token: str, supabase: Any, institute_ids: List[str]):
|
||||
self.user_id = user_id
|
||||
self.access_token = access_token
|
||||
self.supabase = supabase
|
||||
self.institute_ids = institute_ids
|
||||
|
||||
def resolve_institute(self, requested: Optional[str]) -> str:
|
||||
"""Validate a client-supplied institute_id, or pick the sole membership.
|
||||
|
||||
R5.5: a client-supplied institute_id is never trusted as the authz signal — it must
|
||||
be one the caller actually belongs to. RLS would reject a bad value at write time
|
||||
anyway; resolving here turns that into a clean 400/403 instead of an opaque DB error.
|
||||
"""
|
||||
if requested:
|
||||
if requested not in self.institute_ids:
|
||||
raise HTTPException(status_code=403, detail="Not a member of the requested institute")
|
||||
return requested
|
||||
if len(self.institute_ids) == 1:
|
||||
return self.institute_ids[0]
|
||||
if not self.institute_ids:
|
||||
raise HTTPException(status_code=403, detail="Caller has no institute membership")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="institute_id is required when the caller belongs to multiple institutes",
|
||||
)
|
||||
|
||||
|
||||
def _extract_institute_ids(rpc_data: Any) -> List[str]:
|
||||
"""Normalise the user_institute_ids() RPC result to a list of uuid strings.
|
||||
|
||||
A `returns setof uuid` function comes back from PostgREST as a JSON array of scalars,
|
||||
but tolerate the `[{"user_institute_ids": "..."}]` shape too in case of driver quirks.
|
||||
"""
|
||||
out: List[str] = []
|
||||
for row in rpc_data or []:
|
||||
if isinstance(row, dict):
|
||||
val = row.get("user_institute_ids") or next(iter(row.values()), None)
|
||||
else:
|
||||
val = row
|
||||
if val:
|
||||
out.append(str(val))
|
||||
return out
|
||||
|
||||
|
||||
async def get_exam_context(payload: Dict[str, Any] = Depends(auth)) -> ExamContext:
|
||||
user_id = payload.get("sub") or payload.get("user_id")
|
||||
access_token = payload.get("_access_token")
|
||||
if not user_id or not access_token:
|
||||
raise HTTPException(status_code=401, detail="Invalid token payload")
|
||||
|
||||
supabase = SupabaseAnonClient.for_user(access_token).supabase
|
||||
|
||||
try:
|
||||
res = supabase.rpc("user_institute_ids").execute()
|
||||
institute_ids = _extract_institute_ids(getattr(res, "data", None))
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to resolve institute memberships: {exc}")
|
||||
raise HTTPException(status_code=502, detail="Could not resolve institute membership")
|
||||
|
||||
return ExamContext(user_id, access_token, supabase, institute_ids)
|
||||
|
||||
|
||||
def resolve_student_names(student_ids: List[str]) -> Dict[str, str]:
|
||||
"""Map profile id → display name for roster students (batch-creation denormalisation).
|
||||
|
||||
Documented service-role exception (S1, mirrors lookup_exam_code): `profiles` has no as-user
|
||||
SELECT policy (E4), so the roster's display names can't be read as-the-user. The caller's
|
||||
right to the roster itself is already enforced as-user (class_students.cs_read requires the
|
||||
caller to teach/admin the class); this only resolves names for ids already authorised, and
|
||||
the result is denormalised onto student_submissions so later reads need no profiles access.
|
||||
"""
|
||||
if not student_ids:
|
||||
return {}
|
||||
try:
|
||||
sb = SupabaseServiceRoleClient().supabase
|
||||
res = (
|
||||
sb.table("profiles")
|
||||
.select("id, full_name, display_name, email")
|
||||
.in_("id", list(student_ids))
|
||||
.execute()
|
||||
)
|
||||
out: Dict[str, str] = {}
|
||||
for p in getattr(res, "data", None) or []:
|
||||
out[p["id"]] = p.get("full_name") or p.get("display_name") or p.get("email") or ""
|
||||
return out
|
||||
except Exception as exc:
|
||||
logger.warning(f"student name resolution failed: {exc}")
|
||||
return {}
|
||||
|
||||
|
||||
def lookup_exam_code(exam_id: str) -> Optional[str]:
|
||||
"""Resolve eb_exams.exam_code for a catalogue paper (denormalised onto the template).
|
||||
|
||||
Documented service-role exception (S1): eb_exams is shared exam-board reference data with
|
||||
no as-user SELECT policy (E4), so a normal user cannot read it. This is a read of public
|
||||
catalogue metadata only — not user-scoped data — and is used solely to keep the Neo4j join
|
||||
key (exam_code) correct on the template row.
|
||||
"""
|
||||
try:
|
||||
sb = SupabaseServiceRoleClient().supabase
|
||||
res = sb.table("eb_exams").select("exam_code").eq("id", exam_id).limit(1).execute()
|
||||
rows = getattr(res, "data", None) or []
|
||||
return rows[0].get("exam_code") if rows else None
|
||||
except Exception as exc:
|
||||
logger.warning(f"exam_code lookup failed for exam_id={exam_id}: {exc}")
|
||||
return None
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Pydantic request/response models for the /api/exam/ router (S4-5).
|
||||
|
||||
Templates are saved from the canvas with a full-replace PUT (R5.2): the client owns
|
||||
stable UUIDs for questions / response areas / boundaries so the Supabase ids line up
|
||||
with the Neo4j join keys (exam_questions.id ↔ Question|Part.uuid_string,
|
||||
exam_response_areas.id ↔ Region.uuid_string — see spec §2). Granular mark-scheme edits
|
||||
go through PATCH /api/exam/questions/{qid}.
|
||||
|
||||
Models mirror the columns in volumes/db/cc/72-exam-marker.sql. They are intentionally
|
||||
permissive (most fields optional) so the canvas can round-trip partial state during
|
||||
authoring without the API rejecting work-in-progress.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ─── Templates ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class CreateTemplateRequest(BaseModel):
|
||||
title: str
|
||||
subject: Optional[str] = None
|
||||
# Catalogue paper (eb_exams) the template maps, when chosen from the catalogue (R2.2).
|
||||
exam_id: Optional[str] = None
|
||||
# Denormalised onto the template for the Neo4j join (eb_exams.exam_code ↔ ExamPaper.exam_code).
|
||||
# If exam_id is given but exam_code is omitted, the API resolves it from the catalogue.
|
||||
exam_code: Optional[str] = None
|
||||
# Uploaded PDF (files.id) for an ad-hoc paper (R2.2).
|
||||
source_file_id: Optional[str] = None
|
||||
page_count: Optional[int] = None
|
||||
# Active institute (R1.4/R5.5). Validated against the caller's memberships; never trusted
|
||||
# as the authorization signal. Optional when the caller belongs to exactly one institute.
|
||||
institute_id: Optional[str] = None
|
||||
|
||||
|
||||
class UpdateTemplateMetaRequest(BaseModel):
|
||||
"""Template-level fields that a full-replace PUT may also update alongside the canvas."""
|
||||
title: Optional[str] = None
|
||||
subject: Optional[str] = None
|
||||
page_count: Optional[int] = None
|
||||
status: Optional[Literal["draft", "ready", "archived"]] = None
|
||||
|
||||
|
||||
# ─── Canvas entities (children of a template) ────────────────────────────────────
|
||||
|
||||
class QuestionPayload(BaseModel):
|
||||
# Client-supplied stable UUID (== Neo4j Question|Part.uuid_string). Optional on first save.
|
||||
id: Optional[str] = None
|
||||
parent_id: Optional[str] = None
|
||||
label: str
|
||||
order: int = 0
|
||||
max_marks: float = 0
|
||||
answer_type: Optional[Literal["written", "mcq", "short", "diagram"]] = None
|
||||
mcq_options: Optional[Any] = None
|
||||
mark_scheme: Dict[str, Any] = Field(default_factory=dict)
|
||||
is_container: bool = False
|
||||
spec_ref: Optional[str] = None
|
||||
|
||||
|
||||
class ResponseAreaPayload(BaseModel):
|
||||
id: Optional[str] = None # == Neo4j Region.uuid_string
|
||||
question_id: str
|
||||
page: int
|
||||
bounds: Dict[str, Any] # {x,y,w,h}
|
||||
kind: Literal["response", "context"]
|
||||
response_form: Optional[
|
||||
Literal["lines", "answer-box", "working", "diagram", "tick-boxes", "table", "blanks"]
|
||||
] = None
|
||||
source: Literal["manual", "ai"] = "manual"
|
||||
confirmed: bool = True
|
||||
confidence: Optional[float] = None
|
||||
|
||||
|
||||
class BoundaryPayload(BaseModel):
|
||||
id: Optional[str] = None
|
||||
question_id: Optional[str] = None
|
||||
label: Optional[str] = None
|
||||
page_index: int
|
||||
y: float
|
||||
bounds: Optional[Dict[str, Any]] = None
|
||||
source: Literal["manual", "ai"] = "manual"
|
||||
confirmed: bool = True
|
||||
|
||||
|
||||
class TemplateReplaceRequest(BaseModel):
|
||||
"""Full-replace canvas save (R5.2 primary path). All children are replaced wholesale."""
|
||||
meta: Optional[UpdateTemplateMetaRequest] = None
|
||||
questions: List[QuestionPayload] = Field(default_factory=list)
|
||||
response_areas: List[ResponseAreaPayload] = Field(default_factory=list)
|
||||
boundaries: List[BoundaryPayload] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PatchQuestionRequest(BaseModel):
|
||||
"""Incremental mark-scheme / spec-ref edit (R5.2 granular path)."""
|
||||
label: Optional[str] = None
|
||||
order: Optional[int] = None
|
||||
max_marks: Optional[float] = None
|
||||
answer_type: Optional[Literal["written", "mcq", "short", "diagram"]] = None
|
||||
mcq_options: Optional[Any] = None
|
||||
mark_scheme: Optional[Dict[str, Any]] = None
|
||||
is_container: Optional[bool] = None
|
||||
spec_ref: Optional[str] = None
|
||||
|
||||
|
||||
# ─── Marking batches & marks ─────────────────────────────────────────────────
|
||||
|
||||
class CreateBatchRequest(BaseModel):
|
||||
template_id: str
|
||||
# When a class is given, the roster (class_students, status='active') is materialised as
|
||||
# student_submissions(status='absent') so every enrolled student appears in results (A7).
|
||||
class_id: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
|
||||
|
||||
class MarkUpsertRequest(BaseModel):
|
||||
"""Upsert one mark entry (PUT /marks/{id}; id is the mark_entry uuid).
|
||||
|
||||
batch_id is derived server-side from the submission, so the client never sets the RLS
|
||||
scoping key. submission_id + question_id identify what is being marked.
|
||||
"""
|
||||
submission_id: str
|
||||
question_id: str
|
||||
awarded_marks: float = 0
|
||||
mark_scheme_detail: Optional[Dict[str, Any]] = None
|
||||
annotation_shape_ids: Optional[Any] = None
|
||||
comment: Optional[str] = None
|
||||
confirmed: Optional[bool] = None
|
||||
@@ -0,0 +1,309 @@
|
||||
"""Template CRUD for the exam-marker (/api/exam/templates...) — card S4-5.
|
||||
|
||||
All access is as-the-user (RLS-enforced; spec E1 fix) via ExamContext. Ownership is also
|
||||
checked explicitly before mutating (E2: never trust a client-supplied id as authorization) —
|
||||
defence in depth on top of RLS. A row the caller cannot see under RLS reads back as absent,
|
||||
so cross-institute access surfaces as 404, never a data leak (IDOR-safe).
|
||||
|
||||
Hybrid persistence (R5.2): PUT /templates/{id} is a full-replace of the canvas children
|
||||
(questions + response areas + boundaries); PATCH /questions/{qid} is the granular mark-scheme
|
||||
edit path. Client-supplied UUIDs are preserved so Supabase ids stay aligned with the Neo4j
|
||||
join keys (spec §2).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
|
||||
from modules.database.services.exam_projection import project_template, project_template_safe
|
||||
from modules.logger_tool import initialise_logger
|
||||
from routers.exam.dependencies import ExamContext, get_exam_context, lookup_exam_code
|
||||
from routers.exam.schemas import (
|
||||
CreateTemplateRequest,
|
||||
PatchQuestionRequest,
|
||||
TemplateReplaceRequest,
|
||||
)
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _rows(result: Any) -> List[Dict[str, Any]]:
|
||||
data = getattr(result, "data", None)
|
||||
if not data:
|
||||
return []
|
||||
return data if isinstance(data, list) else [data]
|
||||
|
||||
|
||||
def _first(result: Any) -> Optional[Dict[str, Any]]:
|
||||
rows = _rows(result)
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def _fetch_template_or_404(ctx: ExamContext, template_id: str) -> Dict[str, Any]:
|
||||
"""Load a template the caller can see (RLS-scoped). Missing/forbidden → 404."""
|
||||
res = ctx.supabase.table("exam_templates").select("*").eq("id", template_id).limit(1).execute()
|
||||
row = _first(res)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
return row
|
||||
|
||||
|
||||
def _require_owner(ctx: ExamContext, template: Dict[str, Any]) -> None:
|
||||
"""Writes are limited to the owning teacher (R2.4). RLS also enforces this; we pre-check
|
||||
so a colleague who can *read* the template gets a clean 403 instead of a silent no-op."""
|
||||
if template.get("teacher_id") != ctx.user_id:
|
||||
raise HTTPException(status_code=403, detail="Only the template owner can modify it")
|
||||
|
||||
|
||||
def _template_has_recorded_marks(ctx: ExamContext, template_id: str) -> bool:
|
||||
"""True if any mark_entry exists for a batch of this template (→ destructive PUT is unsafe)."""
|
||||
batches = _rows(
|
||||
ctx.supabase.table("marking_batches").select("id").eq("template_id", template_id).execute()
|
||||
)
|
||||
batch_ids = [b["id"] for b in batches]
|
||||
if not batch_ids:
|
||||
return False
|
||||
marks = _rows(
|
||||
ctx.supabase.table("mark_entries").select("id").in_("batch_id", batch_ids).limit(1).execute()
|
||||
)
|
||||
return bool(marks)
|
||||
|
||||
|
||||
# ─── templates ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/templates")
|
||||
async def create_template(
|
||||
body: CreateTemplateRequest,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
institute_id = ctx.resolve_institute(body.institute_id)
|
||||
|
||||
exam_code = body.exam_code
|
||||
if body.exam_id and not exam_code:
|
||||
exam_code = lookup_exam_code(body.exam_id)
|
||||
|
||||
row = {
|
||||
"title": body.title,
|
||||
"subject": body.subject,
|
||||
"exam_id": body.exam_id,
|
||||
"exam_code": exam_code,
|
||||
"source_file_id": body.source_file_id,
|
||||
"page_count": body.page_count,
|
||||
"institute_id": institute_id,
|
||||
"teacher_id": ctx.user_id,
|
||||
"status": "draft",
|
||||
}
|
||||
row = {k: v for k, v in row.items() if v is not None}
|
||||
|
||||
res = ctx.supabase.table("exam_templates").insert(row).execute()
|
||||
created = _first(res)
|
||||
if not created:
|
||||
raise HTTPException(status_code=500, detail="Failed to create template")
|
||||
logger.info(f"Exam template created: {created.get('id')} by {ctx.user_id}")
|
||||
return created
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
async def list_templates(
|
||||
include_archived: bool = False,
|
||||
institute_id: Optional[str] = None,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
# RLS already scopes to the caller's institutes; the optional filter narrows within that.
|
||||
q = ctx.supabase.table("exam_templates").select("*")
|
||||
if institute_id:
|
||||
q = q.eq("institute_id", institute_id)
|
||||
if not include_archived:
|
||||
q = q.neq("status", "archived")
|
||||
res = q.order("updated_at", desc=True).execute()
|
||||
return {"templates": _rows(res)}
|
||||
|
||||
|
||||
@router.get("/templates/{template_id}")
|
||||
async def get_template(
|
||||
template_id: str,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
template = _fetch_template_or_404(ctx, template_id)
|
||||
questions = _rows(
|
||||
ctx.supabase.table("exam_questions").select("*").eq("template_id", template_id).order("order").execute()
|
||||
)
|
||||
response_areas = _rows(
|
||||
ctx.supabase.table("exam_response_areas").select("*").eq("template_id", template_id).execute()
|
||||
)
|
||||
boundaries = _rows(
|
||||
ctx.supabase.table("exam_boundaries").select("*").eq("template_id", template_id).execute()
|
||||
)
|
||||
return {
|
||||
**template,
|
||||
"questions": questions,
|
||||
"response_areas": response_areas,
|
||||
"boundaries": boundaries,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/templates/{template_id}")
|
||||
async def replace_template(
|
||||
template_id: str,
|
||||
body: TemplateReplaceRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
"""Full-replace canvas save (R5.2). Replaces questions/response_areas/boundaries wholesale.
|
||||
|
||||
Note: the delete-then-insert spans several PostgREST calls and is therefore not atomic;
|
||||
acceptable for the small (~20-question) payloads this carries. A transactional RPC is a
|
||||
later hardening step if concurrent canvas saves become a concern.
|
||||
"""
|
||||
template = _fetch_template_or_404(ctx, template_id)
|
||||
_require_owner(ctx, template)
|
||||
|
||||
# Data-loss guard: the wholesale question delete below cascades to mark_entries
|
||||
# (mark_entries.question_id → exam_questions ON DELETE CASCADE). Refuse a structural
|
||||
# full-replace once any marks have been recorded against this template's batches, so
|
||||
# re-saving the setup canvas mid-marking can't silently wipe a teacher's marking work.
|
||||
# (Mark-scheme tweaks use PATCH /questions/{id}, which is unaffected.)
|
||||
if _template_has_recorded_marks(ctx, template_id):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Template has recorded marks; structural full-replace is blocked. "
|
||||
"Edit questions individually via PATCH /questions/{id}.",
|
||||
)
|
||||
|
||||
# Optional template-level metadata update alongside the canvas.
|
||||
if body.meta:
|
||||
updates = {k: v for k, v in body.meta.dict().items() if v is not None}
|
||||
if updates:
|
||||
ctx.supabase.table("exam_templates").update(updates).eq("id", template_id).execute()
|
||||
|
||||
sb = ctx.supabase
|
||||
# Clear existing children. Order matters: response_areas/boundaries reference questions, so
|
||||
# remove them first (we delete by template_id rather than rely on cascade for predictability).
|
||||
sb.table("exam_response_areas").delete().eq("template_id", template_id).execute()
|
||||
sb.table("exam_boundaries").delete().eq("template_id", template_id).execute()
|
||||
sb.table("exam_questions").delete().eq("template_id", template_id).execute()
|
||||
|
||||
# Re-insert, preserving client-supplied UUIDs (Neo4j join keys, spec §2).
|
||||
if body.questions:
|
||||
q_rows = []
|
||||
for q in body.questions:
|
||||
r = {
|
||||
"template_id": template_id,
|
||||
"parent_id": q.parent_id,
|
||||
"label": q.label,
|
||||
"order": q.order,
|
||||
"max_marks": q.max_marks,
|
||||
"answer_type": q.answer_type,
|
||||
"mcq_options": q.mcq_options,
|
||||
"mark_scheme": q.mark_scheme,
|
||||
"is_container": q.is_container,
|
||||
"spec_ref": q.spec_ref,
|
||||
}
|
||||
if q.id:
|
||||
r["id"] = q.id
|
||||
q_rows.append({k: v for k, v in r.items() if v is not None})
|
||||
sb.table("exam_questions").insert(q_rows).execute()
|
||||
|
||||
if body.response_areas:
|
||||
ra_rows = []
|
||||
for ra in body.response_areas:
|
||||
r = {
|
||||
"template_id": template_id,
|
||||
"question_id": ra.question_id,
|
||||
"page": ra.page,
|
||||
"bounds": ra.bounds,
|
||||
"kind": ra.kind,
|
||||
"response_form": ra.response_form,
|
||||
"source": ra.source,
|
||||
"confirmed": ra.confirmed,
|
||||
"confidence": ra.confidence,
|
||||
}
|
||||
if ra.id:
|
||||
r["id"] = ra.id
|
||||
ra_rows.append({k: v for k, v in r.items() if v is not None})
|
||||
sb.table("exam_response_areas").insert(ra_rows).execute()
|
||||
|
||||
if body.boundaries:
|
||||
b_rows = []
|
||||
for b in body.boundaries:
|
||||
r = {
|
||||
"template_id": template_id,
|
||||
"question_id": b.question_id,
|
||||
"label": b.label,
|
||||
"page_index": b.page_index,
|
||||
"y": b.y,
|
||||
"bounds": b.bounds,
|
||||
"source": b.source,
|
||||
"confirmed": b.confirmed,
|
||||
}
|
||||
if b.id:
|
||||
r["id"] = b.id
|
||||
b_rows.append({k: v for k, v in r.items() if v is not None})
|
||||
sb.table("exam_boundaries").insert(b_rows).execute()
|
||||
|
||||
logger.info(
|
||||
f"Exam template {template_id} replaced: {len(body.questions)} questions, "
|
||||
f"{len(body.response_areas)} regions, {len(body.boundaries)} boundaries"
|
||||
)
|
||||
# R3.5.4: a successful save enqueues a graph projection into cc.public.exams. BackgroundTasks
|
||||
# is acceptable for Sprint 4 (durability via a real queue is a later step); failures are
|
||||
# swallowed so the canvas save itself never fails on a graph hiccup.
|
||||
background_tasks.add_task(project_template_safe, template_id)
|
||||
return await get_template(template_id, ctx)
|
||||
|
||||
|
||||
@router.delete("/templates/{template_id}")
|
||||
async def archive_template(
|
||||
template_id: str,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
"""Soft-delete: status='archived' (R5.2). Never hard-deletes a teacher's work."""
|
||||
template = _fetch_template_or_404(ctx, template_id)
|
||||
_require_owner(ctx, template)
|
||||
ctx.supabase.table("exam_templates").update({"status": "archived"}).eq("id", template_id).execute()
|
||||
return {"status": "archived", "id": template_id}
|
||||
|
||||
|
||||
@router.post("/templates/{template_id}/neo4j-sync")
|
||||
async def neo4j_sync(
|
||||
template_id: str,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
"""Manual graph-projection trigger (R5.3) for dev/backfill — runs synchronously and returns
|
||||
counts. Auth/ownership is checked as-the-user; the projection itself uses service role
|
||||
(R3.5.1, the documented graph-writer path)."""
|
||||
template = _fetch_template_or_404(ctx, template_id)
|
||||
_require_owner(ctx, template)
|
||||
try:
|
||||
counts = project_template(template_id)
|
||||
except Exception as exc:
|
||||
logger.error(f"Manual neo4j-sync failed for template {template_id}: {exc}")
|
||||
raise HTTPException(status_code=502, detail=f"Projection failed: {exc}")
|
||||
return {"status": "ok", "projection": counts}
|
||||
|
||||
|
||||
# ─── questions (granular edit path, R5.2) ────────────────────────────────────
|
||||
|
||||
@router.patch("/questions/{question_id}")
|
||||
async def patch_question(
|
||||
question_id: str,
|
||||
body: PatchQuestionRequest,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
updates = {k: v for k, v in body.dict().items() if v is not None}
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
# RLS (exam_questions_all) enforces that the question belongs to a template owned by the
|
||||
# caller; an out-of-scope id updates zero rows → 404, so no explicit pre-fetch is needed.
|
||||
res = ctx.supabase.table("exam_questions").update(updates).eq("id", question_id).execute()
|
||||
updated = _first(res)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Question not found")
|
||||
return updated
|
||||
@@ -16,7 +16,7 @@ logging = logger.get_logger(
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from langchain_classic.chains import GraphCypherQAChain
|
||||
from langchain_community.graphs import Neo4jGraph
|
||||
from langchain_community.chat_models import ChatOpenAI
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_classic.prompts.prompt import PromptTemplate
|
||||
from routers.llm.private.ollama.ollama_wrapper import OllamaWrapper
|
||||
from modules.database.tools.neontology.utils import get_node_types, get_rels_by_type
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from modules.auth.supabase_bearer import SupabaseBearer
|
||||
from modules.database.services.bootstrap_service import build_bootstrap_response
|
||||
|
||||
router = APIRouter()
|
||||
auth_scheme = SupabaseBearer()
|
||||
|
||||
|
||||
@router.get("/bootstrap")
|
||||
async def get_me_bootstrap(credentials: Dict[str, Any] = Depends(auth_scheme)) -> Dict[str, Any]:
|
||||
"""Authenticated Supabase-first session/onboarding bootstrap contract."""
|
||||
try:
|
||||
return build_bootstrap_response(credentials)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
@@ -0,0 +1,65 @@
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
from uuid import uuid4
|
||||
|
||||
import jwt
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from modules.auth.supabase_bearer import verify_supabase_token_dep
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
TLSYNC_TOKEN_AUDIENCE = "tlsync"
|
||||
DEFAULT_TLSYNC_TOKEN_TTL_SECONDS = 300
|
||||
|
||||
|
||||
def _tlsync_token_ttl_seconds() -> int:
|
||||
raw_value = os.getenv("TLSYNC_TOKEN_TTL_SECONDS")
|
||||
if not raw_value:
|
||||
return DEFAULT_TLSYNC_TOKEN_TTL_SECONDS
|
||||
|
||||
try:
|
||||
ttl = int(raw_value)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=500, detail="TLSync token TTL is misconfigured") from exc
|
||||
|
||||
if ttl <= 0 or ttl > 3600:
|
||||
raise HTTPException(status_code=500, detail="TLSync token TTL is out of range")
|
||||
return ttl
|
||||
|
||||
|
||||
def create_tlsync_token(user_claims: Dict[str, Any]) -> Dict[str, Any]:
|
||||
secret = os.getenv("TLSYNC_SECRET")
|
||||
if not secret:
|
||||
raise HTTPException(status_code=503, detail="TLSync authentication is not configured")
|
||||
|
||||
user_id = user_claims.get("sub")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Authenticated user id is missing")
|
||||
|
||||
ttl_seconds = _tlsync_token_ttl_seconds()
|
||||
issued_at = int(time.time())
|
||||
expires_at = issued_at + ttl_seconds
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"aud": TLSYNC_TOKEN_AUDIENCE,
|
||||
"iat": issued_at,
|
||||
"exp": expires_at,
|
||||
"jti": uuid4().hex,
|
||||
}
|
||||
|
||||
token = jwt.encode(payload, secret, algorithm="HS256")
|
||||
return {
|
||||
"token": token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": ttl_seconds,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/token")
|
||||
async def get_tlsync_token(user_claims: Dict[str, Any] = Depends(verify_supabase_token_dep)) -> Dict[str, Any]:
|
||||
"""Issue a short-lived TLSync token for an authenticated Supabase user."""
|
||||
return create_tlsync_token(user_claims)
|
||||
@@ -1,6 +1,4 @@
|
||||
from .infrastructure import initialize_infrastructure
|
||||
from .demo_school import initialize_demo_school
|
||||
from .demo_users import initialize_demo_users
|
||||
from .gais_data import import_gais_data
|
||||
from modules.logger_tool import initialise_logger
|
||||
import os
|
||||
@@ -10,54 +8,32 @@ logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH
|
||||
def initialize_infrastructure_mode() -> None:
|
||||
"""Initialize infrastructure: Neo4j schema, calendar, and Supabase buckets"""
|
||||
logger.info("Starting infrastructure initialization...")
|
||||
|
||||
# 1. Initialize Neo4j database, schema, and calendar structure
|
||||
|
||||
logger.info("Step 1: Initializing Neo4j infrastructure...")
|
||||
from .neo4j import initialize_neo4j
|
||||
neo4j_result = initialize_neo4j()
|
||||
|
||||
|
||||
if not neo4j_result["success"]:
|
||||
logger.error(f"Neo4j infrastructure initialization failed: {neo4j_result['message']}")
|
||||
return
|
||||
|
||||
# 2. Initialize Supabase storage buckets
|
||||
|
||||
logger.info("Step 2: Initializing Supabase storage buckets...")
|
||||
from .buckets import initialize_buckets
|
||||
buckets_result = initialize_buckets()
|
||||
|
||||
|
||||
if not buckets_result["success"]:
|
||||
logger.error(f"Storage buckets initialization failed: {buckets_result['message']}")
|
||||
return
|
||||
|
||||
|
||||
logger.info("Infrastructure initialization completed successfully!")
|
||||
logger.info(f"Neo4j: {neo4j_result['message']}")
|
||||
logger.info(f"Buckets: {buckets_result['message']}")
|
||||
|
||||
def initialize_demo_school_mode() -> None:
|
||||
"""Initialize demo school (KevlarAI)"""
|
||||
logger.info("Starting demo school initialization...")
|
||||
result = initialize_demo_school()
|
||||
|
||||
if result["success"]:
|
||||
logger.info("Demo school initialization completed successfully")
|
||||
else:
|
||||
logger.error(f"Demo school initialization failed: {result['message']}")
|
||||
|
||||
def initialize_demo_users_mode() -> None:
|
||||
"""Initialize demo users"""
|
||||
logger.info("Starting demo users initialization...")
|
||||
result = initialize_demo_users()
|
||||
|
||||
if result["success"]:
|
||||
logger.info("Demo users initialization completed successfully")
|
||||
else:
|
||||
logger.error(f"Demo users initialization failed: {result['message']}")
|
||||
|
||||
def initialize_gais_data_mode() -> None:
|
||||
"""Initialize GAIS data import (Edubase, etc.)"""
|
||||
logger.info("Starting GAIS data import...")
|
||||
result = import_gais_data()
|
||||
|
||||
|
||||
if result["success"]:
|
||||
logger.info("GAIS data import completed successfully")
|
||||
else:
|
||||
@@ -65,11 +41,7 @@ def initialize_gais_data_mode() -> None:
|
||||
|
||||
__all__ = [
|
||||
'initialize_infrastructure_mode',
|
||||
'initialize_demo_school_mode',
|
||||
'initialize_demo_users_mode',
|
||||
'initialize_gais_data_mode',
|
||||
'initialize_infrastructure',
|
||||
'initialize_demo_school',
|
||||
'initialize_demo_users',
|
||||
'import_gais_data'
|
||||
]
|
||||
]
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
"""
|
||||
Demo school initialization module for ClassroomCopilot
|
||||
Creates the KevlarAI demo school
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
from typing import Dict, Any
|
||||
from modules.logger_tool import initialise_logger
|
||||
from modules.database.services.provisioning_service import ProvisioningService
|
||||
import time
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
|
||||
|
||||
class DemoSchoolInitializer:
|
||||
"""Handles demo school creation"""
|
||||
|
||||
def __init__(self, supabase_url: str, service_role_key: str):
|
||||
self.supabase_url = supabase_url
|
||||
self.service_role_key = service_role_key
|
||||
self.supabase_headers = {
|
||||
"apikey": service_role_key,
|
||||
"Authorization": f"Bearer {service_role_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
self.provisioning_service = ProvisioningService()
|
||||
|
||||
def create_kevlarai_school(self) -> Dict[str, Any]:
|
||||
"""Create the KevlarAI demo school"""
|
||||
logger.info("Creating KevlarAI demo school...")
|
||||
|
||||
try:
|
||||
# Check if KevlarAI school already exists
|
||||
response = self._supabase_request_with_retry(
|
||||
'get',
|
||||
f"{self.supabase_url}/rest/v1/institutes",
|
||||
headers=self.supabase_headers,
|
||||
params={
|
||||
"select": "*",
|
||||
"name": "eq.KevlarAI"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
existing_schools = response.json()
|
||||
if existing_schools and len(existing_schools) > 0:
|
||||
logger.info("KevlarAI school already exists")
|
||||
school = existing_schools[0]
|
||||
try:
|
||||
self.provisioning_service.ensure_school(school["id"])
|
||||
except Exception as provisioning_error:
|
||||
logger.warning(f"Provisioning KevlarAI school failed: {provisioning_error}")
|
||||
return {
|
||||
"success": True,
|
||||
"message": "KevlarAI school already exists",
|
||||
"school": school
|
||||
}
|
||||
|
||||
# Create KevlarAI school
|
||||
school_data = {
|
||||
"name": "KevlarAI",
|
||||
"urn": "KEVLARAI001",
|
||||
"status": "active",
|
||||
"address": {
|
||||
"street": "123 Innovation Drive",
|
||||
"town": "Tech City",
|
||||
"county": "Digital County",
|
||||
"postcode": "TC1 2AI",
|
||||
"country": "United Kingdom"
|
||||
},
|
||||
"website": "https://kevlar.ai",
|
||||
"metadata": {
|
||||
"school_type": "AI and Technology",
|
||||
"phase_of_education": "Secondary and Further Education",
|
||||
"establishment_status": "Open",
|
||||
"specialization": "Artificial Intelligence, Machine Learning, Robotics"
|
||||
}
|
||||
}
|
||||
|
||||
# Insert the school
|
||||
response = self._supabase_request_with_retry('post', f"{self.supabase_url}/rest/v1/institutes", headers={**self.supabase_headers, "Prefer": "return=representation"}, json=school_data, params={"select": "*"})
|
||||
|
||||
logger.info(f"Supabase response status: {response.status_code}")
|
||||
logger.info(f"Supabase response headers: {dict(response.headers)}")
|
||||
logger.info(f"Supabase response text: {response.text}")
|
||||
|
||||
if response.status_code in (200, 201):
|
||||
try:
|
||||
data = response.json()
|
||||
school = data[0] if isinstance(data, list) and data else data
|
||||
logger.info("Successfully created KevlarAI school")
|
||||
# Ensure Neo4j provisioning is in place
|
||||
try:
|
||||
self.provisioning_service.ensure_school(school["id"])
|
||||
except Exception as provisioning_error:
|
||||
logger.warning(f"Provisioning KevlarAI school failed: {provisioning_error}")
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Successfully created KevlarAI school",
|
||||
"school": school
|
||||
}
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse JSON response: {str(e)}")
|
||||
logger.error(f"Response text: {response.text}")
|
||||
# If the status code is successful but we can't parse JSON,
|
||||
# the school was likely created successfully
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Successfully created KevlarAI school (response not JSON)",
|
||||
"school": None
|
||||
}
|
||||
else:
|
||||
logger.error(f"Failed to create KevlarAI school: {response.text}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Failed to create KevlarAI school: {response.text}"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating KevlarAI school: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Error creating KevlarAI school: {str(e)}"
|
||||
}
|
||||
|
||||
def _supabase_request_with_retry(self, method, url, **kwargs):
|
||||
"""Make a request to Supabase with retry logic"""
|
||||
max_retries = 3
|
||||
retry_delay = 2 # seconds
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
if method.lower() == 'get':
|
||||
response = requests.get(url, **kwargs)
|
||||
elif method.lower() == 'post':
|
||||
response = requests.post(url, **kwargs)
|
||||
elif method.lower() == 'put':
|
||||
response = requests.put(url, **kwargs)
|
||||
elif method.lower() == 'delete':
|
||||
response = requests.delete(url, **kwargs)
|
||||
else:
|
||||
raise ValueError(f"Unsupported HTTP method: {method}")
|
||||
|
||||
# If successful or client error (4xx), don't retry
|
||||
if response.status_code < 500:
|
||||
return response
|
||||
|
||||
# Server error (5xx), retry after delay
|
||||
logger.warning(f"Supabase server error (attempt {attempt+1}/{max_retries}): {response.status_code} - {response.text}")
|
||||
time.sleep(retry_delay * (attempt + 1)) # Exponential backoff
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.warning(f"Supabase request exception (attempt {attempt+1}/{max_retries}): {str(e)}")
|
||||
if attempt == max_retries - 1:
|
||||
raise
|
||||
time.sleep(retry_delay * (attempt + 1))
|
||||
|
||||
# If we get here, all retries failed with server errors
|
||||
raise requests.RequestException(f"Failed after {max_retries} attempts to {method} {url}")
|
||||
|
||||
def initialize_demo_school() -> Dict[str, Any]:
|
||||
"""Initialize demo school (KevlarAI)"""
|
||||
logger.info("Starting demo school initialization...")
|
||||
|
||||
supabase_url = os.getenv("SUPABASE_URL")
|
||||
service_role_key = os.getenv("SERVICE_ROLE_KEY")
|
||||
|
||||
if not supabase_url or not service_role_key:
|
||||
return {"success": False, "message": "Missing SUPABASE_URL or SERVICE_ROLE_KEY environment variables"}
|
||||
|
||||
initializer = DemoSchoolInitializer(supabase_url, service_role_key)
|
||||
|
||||
# Create KevlarAI school
|
||||
result = initializer.create_kevlarai_school()
|
||||
|
||||
if result["success"]:
|
||||
logger.info("Demo school initialization completed successfully")
|
||||
else:
|
||||
logger.error(f"Demo school initialization failed: {result['message']}")
|
||||
|
||||
return result
|
||||
@@ -1,395 +0,0 @@
|
||||
"""
|
||||
Demo users initialization module for ClassroomCopilot
|
||||
Creates demo teachers and students
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
import time
|
||||
from typing import Dict, Any
|
||||
from modules.logger_tool import initialise_logger
|
||||
from modules.database.services.provisioning_service import ProvisioningService
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
|
||||
|
||||
class DemoUsersInitializer:
|
||||
"""Handles demo users creation"""
|
||||
|
||||
def __init__(self, supabase_url: str, service_role_key: str):
|
||||
self.supabase_url = supabase_url
|
||||
self.service_role_key = service_role_key
|
||||
self.supabase_headers = {
|
||||
"apikey": service_role_key,
|
||||
"Authorization": f"Bearer {service_role_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
self.provisioning_service = ProvisioningService()
|
||||
|
||||
def create_demo_users(self) -> Dict[str, Any]:
|
||||
"""Create demo teachers and students"""
|
||||
logger.info("Creating demo users...")
|
||||
|
||||
try:
|
||||
# Define demo users
|
||||
demo_users = [
|
||||
# Demo Teachers
|
||||
{
|
||||
"email": "[email protected]",
|
||||
"password": "DemoTeacher123!",
|
||||
"email_confirm": True,
|
||||
"user_metadata": {
|
||||
"name": "Dr. Sarah Chen",
|
||||
"username": "sarah.chen",
|
||||
"full_name": "Dr. Sarah Chen",
|
||||
"display_name": "Dr. Chen",
|
||||
"user_type": "teacher"
|
||||
},
|
||||
"app_metadata": {
|
||||
"provider": "email",
|
||||
"providers": ["email"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"email": "[email protected]",
|
||||
"password": "DemoTeacher123!",
|
||||
"email_confirm": True,
|
||||
"user_metadata": {
|
||||
"name": "Prof. Marcus Rodriguez",
|
||||
"username": "marcus.rodriguez",
|
||||
"full_name": "Professor Marcus Rodriguez",
|
||||
"display_name": "Prof. Rodriguez",
|
||||
"user_type": "teacher"
|
||||
},
|
||||
"app_metadata": {
|
||||
"provider": "email",
|
||||
"providers": ["email"]
|
||||
}
|
||||
},
|
||||
# Demo Students
|
||||
{
|
||||
"email": "[email protected]",
|
||||
"password": "DemoStudent123!",
|
||||
"email_confirm": True,
|
||||
"user_metadata": {
|
||||
"name": "Alex Thompson",
|
||||
"username": "alex.thompson",
|
||||
"full_name": "Alex Thompson",
|
||||
"display_name": "Alex",
|
||||
"user_type": "student"
|
||||
},
|
||||
"app_metadata": {
|
||||
"provider": "email",
|
||||
"providers": ["email"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"email": "[email protected]",
|
||||
"password": "DemoStudent123!",
|
||||
"email_confirm": True,
|
||||
"user_metadata": {
|
||||
"name": "Jordan Lee",
|
||||
"username": "jordan.lee",
|
||||
"full_name": "Jordan Lee",
|
||||
"display_name": "Jordan",
|
||||
"user_type": "student"
|
||||
},
|
||||
"app_metadata": {
|
||||
"provider": "email",
|
||||
"providers": ["email"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
created_users = []
|
||||
failed_users = []
|
||||
|
||||
for user_data in demo_users:
|
||||
try:
|
||||
# Create user via Auth API
|
||||
response = self._supabase_request_with_retry(
|
||||
'post',
|
||||
f"{self.supabase_url}/auth/v1/admin/users",
|
||||
headers=self.supabase_headers,
|
||||
json=user_data
|
||||
)
|
||||
|
||||
if response.status_code in (200, 201):
|
||||
user = response.json()
|
||||
user_id = user.get("id")
|
||||
|
||||
# Wait a moment for user to be created
|
||||
time.sleep(1)
|
||||
|
||||
# Create profile
|
||||
profile_data = {
|
||||
"id": user_id,
|
||||
"email": user_data["email"],
|
||||
"user_type": user_data["user_metadata"]["user_type"],
|
||||
"username": user_data["user_metadata"]["username"],
|
||||
"full_name": user_data["user_metadata"]["full_name"],
|
||||
"display_name": user_data["user_metadata"]["display_name"]
|
||||
}
|
||||
|
||||
profile_response = self._supabase_request_with_retry(
|
||||
'post',
|
||||
f"{self.supabase_url}/rest/v1/profiles",
|
||||
headers=self.supabase_headers,
|
||||
json=profile_data
|
||||
)
|
||||
|
||||
if profile_response.status_code in (200, 201):
|
||||
created_users.append({
|
||||
"id": user_id,
|
||||
"email": user_data["email"],
|
||||
"user_type": user_data["user_metadata"]["user_type"],
|
||||
"username": user_data["user_metadata"]["username"]
|
||||
})
|
||||
logger.info(f"Successfully created user: {user_data['email']}")
|
||||
else:
|
||||
logger.warning(f"Failed to create profile for {user_data['email']}: {profile_response.text}")
|
||||
failed_users.append({
|
||||
"email": user_data["email"],
|
||||
"error": f"Profile creation failed: {profile_response.text}"
|
||||
})
|
||||
else:
|
||||
logger.warning(f"Failed to create user {user_data['email']}: {response.text}")
|
||||
failed_users.append({
|
||||
"email": user_data["email"],
|
||||
"error": f"User creation failed: {response.text}"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating user {user_data['email']}: {str(e)}")
|
||||
failed_users.append({
|
||||
"email": user_data["email"],
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
# Create institute memberships for KevlarAI and provision users
|
||||
all_users_to_provision = []
|
||||
|
||||
# Add newly created users
|
||||
if created_users:
|
||||
all_users_to_provision.extend(created_users)
|
||||
self._create_institute_memberships(created_users)
|
||||
|
||||
# Also provision existing users that failed due to email_exists
|
||||
existing_users = []
|
||||
for failed_user in failed_users:
|
||||
if "email_exists" in failed_user.get("error", ""):
|
||||
# Get the existing user ID from Supabase
|
||||
existing_user_id = self._get_existing_user_id(failed_user["email"])
|
||||
if existing_user_id:
|
||||
existing_users.append({
|
||||
"id": existing_user_id,
|
||||
"email": failed_user["email"],
|
||||
"user_type": self._get_user_type_from_email(failed_user["email"]),
|
||||
"username": self._get_username_from_email(failed_user["email"])
|
||||
})
|
||||
|
||||
if existing_users:
|
||||
logger.info(f"Found {len(existing_users)} existing users to provision")
|
||||
all_users_to_provision.extend(existing_users)
|
||||
self._create_institute_memberships(existing_users)
|
||||
|
||||
# Provision all users (new and existing)
|
||||
if all_users_to_provision:
|
||||
self._provision_users(all_users_to_provision)
|
||||
|
||||
logger.info(f"Demo users creation completed: {len(created_users)} created, {len(failed_users)} failed")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Successfully created {len(created_users)} demo users",
|
||||
"created_users": created_users,
|
||||
"failed_users": failed_users
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating demo users: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Error creating demo users: {str(e)}"
|
||||
}
|
||||
|
||||
def _create_institute_memberships(self, users: list) -> None:
|
||||
"""Create institute memberships for users in KevlarAI"""
|
||||
logger.info("Creating institute memberships for demo users...")
|
||||
|
||||
try:
|
||||
# Get KevlarAI institute ID
|
||||
response = self._supabase_request_with_retry(
|
||||
'get',
|
||||
f"{self.supabase_url}/rest/v1/institutes",
|
||||
headers=self.supabase_headers,
|
||||
params={
|
||||
"select": "id",
|
||||
"name": "eq.KevlarAI"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning("Could not get KevlarAI institute ID for memberships")
|
||||
return
|
||||
|
||||
institutes = response.json()
|
||||
if not institutes:
|
||||
logger.warning("KevlarAI institute not found for memberships")
|
||||
return
|
||||
|
||||
institute_id = institutes[0]["id"]
|
||||
|
||||
# Get user profile IDs
|
||||
for user in users:
|
||||
try:
|
||||
profile_response = self._supabase_request_with_retry(
|
||||
'get',
|
||||
f"{self.supabase_url}/rest/v1/profiles",
|
||||
headers=self.supabase_headers,
|
||||
params={
|
||||
"select": "id",
|
||||
"email": f"eq.{user['email']}"
|
||||
}
|
||||
)
|
||||
|
||||
if profile_response.status_code == 200:
|
||||
profiles = profile_response.json()
|
||||
if profiles:
|
||||
profile_id = profiles[0]["id"]
|
||||
|
||||
# Create membership
|
||||
membership_data = {
|
||||
"profile_id": profile_id,
|
||||
"institute_id": institute_id,
|
||||
"role": user["user_type"]
|
||||
}
|
||||
|
||||
membership_response = self._supabase_request_with_retry(
|
||||
'post',
|
||||
f"{self.supabase_url}/rest/v1/institute_memberships",
|
||||
headers=self.supabase_headers,
|
||||
json=membership_data
|
||||
)
|
||||
|
||||
if membership_response.status_code in (200, 201):
|
||||
logger.info(f"Created membership for {user['email']} in KevlarAI")
|
||||
else:
|
||||
logger.warning(f"Failed to create membership for {user['email']}: {membership_response.text}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error creating membership for {user['email']}: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error creating institute memberships: {str(e)}")
|
||||
|
||||
def _get_existing_user_id(self, email: str) -> str:
|
||||
"""Get the user ID for an existing user by email"""
|
||||
try:
|
||||
response = self._supabase_request_with_retry(
|
||||
'get',
|
||||
f"{self.supabase_url}/rest/v1/profiles",
|
||||
headers=self.supabase_headers,
|
||||
params={
|
||||
"select": "id",
|
||||
"email": f"eq.{email}"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
profiles = response.json()
|
||||
if profiles and len(profiles) > 0:
|
||||
return profiles[0].get("id")
|
||||
|
||||
logger.warning(f"Could not find existing user ID for {email}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error getting existing user ID for {email}: {str(e)}")
|
||||
return None
|
||||
|
||||
def _get_user_type_from_email(self, email: str) -> str:
|
||||
"""Get user type from email based on demo user definitions"""
|
||||
if "teacher" in email:
|
||||
return "teacher"
|
||||
elif "student" in email:
|
||||
return "student"
|
||||
return "teacher" # default
|
||||
|
||||
def _get_username_from_email(self, email: str) -> str:
|
||||
"""Get username from email based on demo user definitions"""
|
||||
username_map = {
|
||||
"[email protected]": "sarah.chen",
|
||||
"[email protected]": "marcus.rodriguez",
|
||||
"[email protected]": "alex.thompson",
|
||||
"[email protected]": "jordan.lee"
|
||||
}
|
||||
return username_map.get(email, email.split("@")[0])
|
||||
|
||||
def _provision_users(self, users: list) -> None:
|
||||
"""Provision Neo4j databases for the created demo users."""
|
||||
for user in users:
|
||||
user_id = user.get("id")
|
||||
if not user_id:
|
||||
continue
|
||||
try:
|
||||
self.provisioning_service.ensure_user(user_id)
|
||||
logger.info(f"Provisioned Neo4j resources for {user.get('email')}")
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to provision Neo4j resources for {user.get('email')}: {exc}")
|
||||
|
||||
def _supabase_request_with_retry(self, method, url, **kwargs):
|
||||
"""Make a request to Supabase with retry logic"""
|
||||
max_retries = 3
|
||||
retry_delay = 2 # seconds
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
if method.lower() == 'get':
|
||||
response = requests.get(url, **kwargs)
|
||||
elif method.lower() == 'post':
|
||||
response = requests.post(url, **kwargs)
|
||||
elif method.lower() == 'put':
|
||||
response = requests.put(url, **kwargs)
|
||||
elif method.lower() == 'delete':
|
||||
response = requests.delete(url, **kwargs)
|
||||
else:
|
||||
raise ValueError(f"Unsupported HTTP method: {method}")
|
||||
|
||||
# If successful or client error (4xx), don't retry
|
||||
if response.status_code < 500:
|
||||
return response
|
||||
|
||||
# Server error (5xx), retry after delay
|
||||
logger.warning(f"Supabase server error (attempt {attempt+1}/{max_retries}): {response.status_code} - {response.text}")
|
||||
time.sleep(retry_delay * (attempt + 1)) # Exponential backoff
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.warning(f"Supabase request exception (attempt {attempt+1}/{max_retries}): {str(e)}")
|
||||
if attempt == max_retries - 1:
|
||||
raise
|
||||
time.sleep(retry_delay * (attempt + 1))
|
||||
|
||||
# If we get here, all retries failed with server errors
|
||||
raise requests.RequestException(f"Failed after {max_retries} attempts to {method} {url}")
|
||||
|
||||
def initialize_demo_users() -> Dict[str, Any]:
|
||||
"""Initialize demo users"""
|
||||
logger.info("Starting demo users initialization...")
|
||||
|
||||
supabase_url = os.getenv("SUPABASE_URL")
|
||||
service_role_key = os.getenv("SERVICE_ROLE_KEY")
|
||||
|
||||
if not supabase_url or not service_role_key:
|
||||
return {"success": False, "message": "Missing SUPABASE_URL or SERVICE_ROLE_KEY environment variables"}
|
||||
|
||||
initializer = DemoUsersInitializer(supabase_url, service_role_key)
|
||||
|
||||
# Create demo users
|
||||
result = initializer.create_demo_users()
|
||||
|
||||
if result["success"]:
|
||||
logger.info("Demo users initialization completed successfully")
|
||||
else:
|
||||
logger.error(f"Demo users initialization failed: {result['message']}")
|
||||
|
||||
return result
|
||||
+125
-1025
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
init_exam_graph.py — Initialise the cc.public.exams Neo4j knowledge graph.
|
||||
|
||||
Creates the shared, public exam database, its uniqueness constraints, and seeds the AQA exam
|
||||
board + AQA GCSE Physics (8463) specification with its 8 top-level topic SpecPoints. Idempotent
|
||||
(CREATE DATABASE IF NOT EXISTS / CREATE CONSTRAINT IF NOT EXISTS / MERGE).
|
||||
|
||||
Run inside the ccapi container:
|
||||
python3 -c "from run.initialization.init_exam_graph import init; import json; print(json.dumps(init()))"
|
||||
|
||||
NOTE: the 8 SpecPoints seeded here are the real AQA GCSE Physics *top-level* topics. The full
|
||||
sub-point breakdown (e.g. 4.1.1.1 ...) is a later data-population task (sourceable from the AQA
|
||||
spec PDF via Docling). spec_code AQA-PHYS-8463 is the standalone GCSE Physics code that matches
|
||||
"AQA Physics Paper 1H"; the eb_exams/eb_specifications seed (card S4-3) must use the same code.
|
||||
"""
|
||||
import uuid
|
||||
from typing import Dict, Any
|
||||
|
||||
from modules.database.tools.neo4j_driver_tools import get_driver
|
||||
|
||||
EXAM_DB = "cc.public.exams"
|
||||
NS = uuid.UUID("00000000-0000-0000-0000-00000000e8a1") # stable namespace for deterministic uuids
|
||||
|
||||
BOARD = {"code": "AQA", "name": "AQA"}
|
||||
SPEC = {
|
||||
"spec_code": "AQA-PHYS-8463",
|
||||
"exam_board_code": "AQA",
|
||||
"subject_code": "PHYS",
|
||||
"award_code": "GCSE",
|
||||
"title": "AQA GCSE Physics (8463)",
|
||||
}
|
||||
# Real AQA GCSE Physics (8463) top-level topics (ref = topic number).
|
||||
SPEC_POINTS = [
|
||||
("4.1", "Energy"),
|
||||
("4.2", "Electricity"),
|
||||
("4.3", "Particle model of matter"),
|
||||
("4.4", "Atomic structure"),
|
||||
("4.5", "Forces"),
|
||||
("4.6", "Waves"),
|
||||
("4.7", "Magnetism and electromagnetism"),
|
||||
("4.8", "Space physics"),
|
||||
]
|
||||
|
||||
CONSTRAINTS = [
|
||||
"CREATE CONSTRAINT exam_board_uid IF NOT EXISTS FOR (n:ExamBoard) REQUIRE n.uuid_string IS UNIQUE",
|
||||
"CREATE CONSTRAINT spec_uid IF NOT EXISTS FOR (n:Specification) REQUIRE n.uuid_string IS UNIQUE",
|
||||
"CREATE CONSTRAINT specpoint_uid IF NOT EXISTS FOR (n:SpecPoint) REQUIRE n.uuid_string IS UNIQUE",
|
||||
"CREATE CONSTRAINT exampaper_uid IF NOT EXISTS FOR (n:ExamPaper) REQUIRE n.uuid_string IS UNIQUE",
|
||||
"CREATE CONSTRAINT question_uid IF NOT EXISTS FOR (n:Question) REQUIRE n.uuid_string IS UNIQUE",
|
||||
"CREATE CONSTRAINT part_uid IF NOT EXISTS FOR (n:Part) REQUIRE n.uuid_string IS UNIQUE",
|
||||
"CREATE CONSTRAINT region_uid IF NOT EXISTS FOR (n:Region) REQUIRE n.uuid_string IS UNIQUE",
|
||||
"CREATE CONSTRAINT spec_code_unique IF NOT EXISTS FOR (n:Specification) REQUIRE n.spec_code IS UNIQUE",
|
||||
"CREATE CONSTRAINT exam_code_unique IF NOT EXISTS FOR (n:ExamPaper) REQUIRE n.exam_code IS UNIQUE",
|
||||
"CREATE CONSTRAINT board_code_unique IF NOT EXISTS FOR (n:ExamBoard) REQUIRE n.code IS UNIQUE",
|
||||
]
|
||||
|
||||
|
||||
def _uid(*parts: str) -> str:
|
||||
return str(uuid.uuid5(NS, ":".join(parts)))
|
||||
|
||||
|
||||
def init() -> Dict[str, Any]:
|
||||
driver = get_driver()
|
||||
result: Dict[str, Any] = {"db": EXAM_DB, "constraints": 0, "spec_points": 0}
|
||||
|
||||
# 1. database
|
||||
with driver.session(database="system") as s:
|
||||
s.run(f"CREATE DATABASE `{EXAM_DB}` IF NOT EXISTS").consume()
|
||||
# wait for availability
|
||||
import time
|
||||
for _ in range(30):
|
||||
with driver.session(database="system") as s:
|
||||
st = s.run("SHOW DATABASE $n YIELD currentStatus RETURN currentStatus", n=EXAM_DB).single()
|
||||
if st and st["currentStatus"] == "online":
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
with driver.session(database=EXAM_DB) as s:
|
||||
# 2. constraints
|
||||
for c in CONSTRAINTS:
|
||||
s.run(c).consume()
|
||||
result["constraints"] += 1
|
||||
|
||||
# 3. board + spec
|
||||
board_uid = _uid("ExamBoard", BOARD["code"])
|
||||
spec_uid = _uid("Specification", SPEC["spec_code"])
|
||||
s.run(
|
||||
"MERGE (b:ExamBoard {uuid_string:$uid}) "
|
||||
"SET b.code=$code, b.name=$name, b.node_storage_path=$nsp",
|
||||
uid=board_uid, code=BOARD["code"], name=BOARD["name"],
|
||||
nsp=f"{EXAM_DB}/ExamBoard/{BOARD['code']}",
|
||||
).consume()
|
||||
s.run(
|
||||
"MERGE (sp:Specification {uuid_string:$uid}) "
|
||||
"SET sp.spec_code=$sc, sp.exam_board_code=$ebc, sp.subject_code=$subj, "
|
||||
" sp.award_code=$award, sp.title=$title, sp.node_storage_path=$nsp "
|
||||
"WITH sp MATCH (b:ExamBoard {code:$ebc}) MERGE (b)-[:PUBLISHES]->(sp)",
|
||||
uid=spec_uid, sc=SPEC["spec_code"], ebc=SPEC["exam_board_code"],
|
||||
subj=SPEC["subject_code"], award=SPEC["award_code"], title=SPEC["title"],
|
||||
nsp=f"{EXAM_DB}/Specification/{SPEC['spec_code']}",
|
||||
).consume()
|
||||
|
||||
# 4. spec points
|
||||
for ref, desc in SPEC_POINTS:
|
||||
sp_uid = _uid("SpecPoint", SPEC["spec_code"], ref)
|
||||
s.run(
|
||||
"MERGE (p:SpecPoint {uuid_string:$uid}) "
|
||||
"SET p.ref=$ref, p.description=$desc, p.spec_code=$sc, "
|
||||
" p.exam_board_code=$ebc, p.node_storage_path=$nsp "
|
||||
"WITH p MATCH (s:Specification {spec_code:$sc}) MERGE (s)-[:HAS_SPEC_POINT]->(p)",
|
||||
uid=sp_uid, ref=ref, desc=desc, sc=SPEC["spec_code"],
|
||||
ebc=SPEC["exam_board_code"], nsp=f"{EXAM_DB}/SpecPoint/{SPEC['spec_code']}/{ref}",
|
||||
).consume()
|
||||
result["spec_points"] += 1
|
||||
|
||||
counts = s.run(
|
||||
"MATCH (b:ExamBoard) WITH count(b) AS boards "
|
||||
"MATCH (sp:Specification) WITH boards, count(sp) AS specs "
|
||||
"MATCH (p:SpecPoint) RETURN boards, specs, count(p) AS spec_points"
|
||||
).single()
|
||||
result["verify"] = dict(counts) if counts else {}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
print(json.dumps(init(), indent=2, default=str))
|
||||
@@ -0,0 +1,230 @@
|
||||
"""
|
||||
reset_environment.py — DESTRUCTIVE wipe of all non-permanent data.
|
||||
|
||||
Clears:
|
||||
- Neo4j: drops ALL databases except system, neo4j (including gaisdata, cc.users.*, cc.institutes.*)
|
||||
- Supabase: deletes ALL data tables except gais_local_authorities and gais_schools
|
||||
- Supabase: deletes all auth users except kcar, then re-seeds kcar profile state
|
||||
|
||||
Safe invariants (never touched):
|
||||
- kcar auth account
|
||||
- gais_local_authorities and gais_schools Supabase tables
|
||||
- system / neo4j Neo4j system databases
|
||||
|
||||
Run from inside the ccapi container:
|
||||
python3 -c "from run.initialization.reset_environment import reset; reset()"
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import requests
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from modules.logger_tool import initialise_logger
|
||||
import modules.database.tools.neo4j_driver_tools as dt
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
|
||||
|
||||
KCAR_ID = "d9e1d1a9-04c4-4611-bb05-57babf4a9a28"
|
||||
KCAR_EMAIL = "[email protected]"
|
||||
|
||||
# Neo4j system databases — never drop these
|
||||
NEO4J_SYSTEM_DBS = {"system", "neo4j"}
|
||||
|
||||
# Supabase tables to clear, in FK child-first order.
|
||||
# gais_local_authorities and gais_schools are intentionally absent.
|
||||
SUPABASE_TABLES_TO_CLEAR = [
|
||||
# ── Transcription (deepest children first) ───────────────────────────────
|
||||
"canvas_events",
|
||||
"keyword_events",
|
||||
"transcription_summaries",
|
||||
"transcription_segments",
|
||||
"keyword_watches",
|
||||
"transcription_sessions",
|
||||
# ── Lesson delivery chain ────────────────────────────────────────────────
|
||||
"lesson_deliveries",
|
||||
"lesson_collaborators",
|
||||
# ── Timetable materialization ────────────────────────────────────────────
|
||||
"taught_lessons",
|
||||
# ── Academic calendar (children → parents) ───────────────────────────────
|
||||
"academic_periods",
|
||||
"academic_days",
|
||||
"academic_weeks",
|
||||
"academic_term_breaks",
|
||||
"academic_terms",
|
||||
"academic_years",
|
||||
# ── Teacher timetables ───────────────────────────────────────────────────
|
||||
"teacher_timetable_slots",
|
||||
"teacher_timetables",
|
||||
"school_timetables",
|
||||
# ── Lesson plans ─────────────────────────────────────────────────────────
|
||||
"planned_lessons",
|
||||
# ── Whiteboard rooms ─────────────────────────────────────────────────────
|
||||
"whiteboard_rooms",
|
||||
# ── Classes & enrollment ─────────────────────────────────────────────────
|
||||
"enrollment_requests",
|
||||
"class_students",
|
||||
"class_teachers",
|
||||
"classes",
|
||||
# ── Files & brains ───────────────────────────────────────────────────────
|
||||
"document_artefacts",
|
||||
"brain_files",
|
||||
"cabinet_memberships",
|
||||
"files",
|
||||
"file_cabinets",
|
||||
"brains",
|
||||
# ── Invitations & memberships ────────────────────────────────────────────
|
||||
"invitations",
|
||||
"institute_memberships",
|
||||
"institute_membership_requests",
|
||||
# ── Institutes ───────────────────────────────────────────────────────────
|
||||
"institutes",
|
||||
# ── Profiles (non-kcar cleared separately via auth deletion cascade) ─────
|
||||
"admin_profiles",
|
||||
]
|
||||
|
||||
|
||||
def _sb_headers():
|
||||
url = os.environ["SUPABASE_URL"]
|
||||
key = os.environ["SERVICE_ROLE_KEY"]
|
||||
return url, {
|
||||
"apikey": key,
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json",
|
||||
"Prefer": "return=minimal",
|
||||
}
|
||||
|
||||
|
||||
# ─── Neo4j helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def _neo4j_drop_all_non_system() -> Dict[str, List[str]]:
|
||||
"""Drop every Neo4j DB except the system-reserved ones."""
|
||||
with dt.get_session(database="system") as s:
|
||||
all_dbs = [r["name"] for r in s.run("SHOW DATABASES YIELD name RETURN name")]
|
||||
|
||||
to_drop = [db for db in all_dbs if db not in NEO4J_SYSTEM_DBS]
|
||||
dropped = []
|
||||
for db in to_drop:
|
||||
logger.info(f" DROP DATABASE `{db}`")
|
||||
try:
|
||||
with dt.get_session(database="system") as s:
|
||||
s.run(f"DROP DATABASE `{db}` IF EXISTS")
|
||||
dropped.append(db)
|
||||
except Exception as e:
|
||||
logger.warning(f" Could not drop `{db}`: {e}")
|
||||
return dropped
|
||||
|
||||
|
||||
# ─── Supabase helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
# Tables without an uid=1000(kcar) gid=1000(kcar) groups=1000(kcar),27(sudo),119(docker) column — map to the column to use as the delete filter.
|
||||
TABLE_FILTER_COLUMN = {
|
||||
"brain_files": "brain_id",
|
||||
}
|
||||
|
||||
def _sb_clear_table(url: str, headers: dict, table: str) -> int:
|
||||
"""Delete all rows from a Supabase table. Returns HTTP status."""
|
||||
col = TABLE_FILTER_COLUMN.get(table, "id")
|
||||
r = requests.delete(
|
||||
f"{url}/rest/v1/{table}",
|
||||
headers=headers,
|
||||
params={col: "not.is.null"},
|
||||
)
|
||||
if r.status_code not in (200, 204):
|
||||
logger.warning(f" Clear {table}: {r.status_code} {r.text[:120]}")
|
||||
return r.status_code
|
||||
|
||||
|
||||
def _supabase_list_auth_users(url: str, headers: dict) -> List[Dict]:
|
||||
r = requests.get(f"{url}/auth/v1/admin/users", headers=headers, params={"per_page": 200})
|
||||
r.raise_for_status()
|
||||
return r.json().get("users", [])
|
||||
|
||||
|
||||
def _supabase_delete_auth_user(url: str, headers: dict, uid: str):
|
||||
r = requests.delete(f"{url}/auth/v1/admin/users/{uid}", headers=headers)
|
||||
if r.status_code not in (200, 204):
|
||||
logger.warning(f" Delete auth user {uid}: {r.status_code} {r.text[:80]}")
|
||||
|
||||
|
||||
# ─── Main reset ───────────────────────────────────────────────────────────────
|
||||
|
||||
def reset() -> Dict[str, Any]:
|
||||
logger.info("=" * 60)
|
||||
logger.info("RESET ENVIRONMENT — full destructive wipe starting")
|
||||
logger.info("=" * 60)
|
||||
results: Dict[str, Any] = {}
|
||||
|
||||
# ── 1. Neo4j: drop everything except system + neo4j ──────────────────────
|
||||
logger.info("\n[Neo4j] Dropping all non-system databases...")
|
||||
dropped = _neo4j_drop_all_non_system()
|
||||
logger.info(f" Dropped {len(dropped)}: {dropped}")
|
||||
results["neo4j"] = {"dropped": dropped}
|
||||
|
||||
# ── 2. Supabase: clear all data tables (GAIS preserved) ──────────────────
|
||||
logger.info("\n[Supabase] Clearing data tables (preserving gais_*)...")
|
||||
url, headers = _sb_headers()
|
||||
cleared, failed = [], []
|
||||
for table in SUPABASE_TABLES_TO_CLEAR:
|
||||
status = _sb_clear_table(url, headers, table)
|
||||
if status in (200, 204):
|
||||
cleared.append(table)
|
||||
logger.info(f" ✓ {table}")
|
||||
else:
|
||||
failed.append(table)
|
||||
logger.info(f" Cleared {len(cleared)} tables, {len(failed)} failed")
|
||||
|
||||
# ── 3. Supabase: delete all auth users except kcar ────────────────────────
|
||||
logger.info("\n[Supabase] Deleting test auth users...")
|
||||
all_users = _supabase_list_auth_users(url, headers)
|
||||
deleted_emails = []
|
||||
for u in all_users:
|
||||
if u["email"] == KCAR_EMAIL:
|
||||
continue
|
||||
_supabase_delete_auth_user(url, headers, u["id"])
|
||||
deleted_emails.append(u["email"])
|
||||
time.sleep(0.05)
|
||||
logger.info(f" Deleted {len(deleted_emails)} auth users")
|
||||
|
||||
# Explicit cleanup in case cascade didn't fire
|
||||
requests.delete(f"{url}/rest/v1/profiles", headers=headers,
|
||||
params={"id": f"neq.{KCAR_ID}"})
|
||||
|
||||
# ── 4. Reset kcar profile to known-good platform_admin state ──────────────
|
||||
logger.info("\n[Supabase] Resetting kcar profile...")
|
||||
requests.patch(
|
||||
f"{url}/rest/v1/profiles",
|
||||
headers=headers,
|
||||
params={"id": f"eq.{KCAR_ID}"},
|
||||
json={"school_id": None},
|
||||
)
|
||||
logger.info(" kcar → school_id: null ✓")
|
||||
|
||||
# Restore admin_profiles row (wiped with other tables above)
|
||||
requests.post(
|
||||
f"{url}/rest/v1/admin_profiles",
|
||||
headers={**headers, "Prefer": "resolution=merge-duplicates"},
|
||||
json={
|
||||
"id": KCAR_ID,
|
||||
"email": KCAR_EMAIL,
|
||||
"display_name": "Kevin Carroll",
|
||||
"admin_role": "super_admin",
|
||||
"is_super_admin": True,
|
||||
},
|
||||
)
|
||||
logger.info(" kcar → admin_profiles restored ✓")
|
||||
|
||||
results["supabase"] = {
|
||||
"tables_cleared": cleared,
|
||||
"tables_failed": failed,
|
||||
"deleted_users": deleted_emails,
|
||||
}
|
||||
|
||||
logger.info("\n" + "=" * 60)
|
||||
logger.info("RESET COMPLETE")
|
||||
logger.info("=" * 60)
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
print(json.dumps(reset(), indent=2, default=str))
|
||||
@@ -0,0 +1,218 @@
|
||||
"""
|
||||
seed_cohort_9p_ph1.py — Markable cohort for exam-marker testing.
|
||||
|
||||
Creates N student accounts and enrols them ALL into a single class (default the
|
||||
Greenfield Year 9 Physics class `9P/Ph1`), so there is a real cohort to mark.
|
||||
|
||||
Why: the canonical timetable seeds enrol "one student per year-group band"
|
||||
(seed_greenfield_timetable.py), so every class has <=1 student — too few for a
|
||||
results table / per-question stats. This seeder fills one class to a usable size.
|
||||
|
||||
Mechanics (identical paths to the canonical seeds — nothing bespoke server-side):
|
||||
- auth user: POST {SUPABASE_URL}/auth/v1/admin/users
|
||||
- profile: upsert public.profiles (school_id = institute)
|
||||
- membership: upsert public.institute_memberships (role 'student')
|
||||
- enrolment: POST {API_BASE_URL}/database/timetable/classes/{class_id}/students
|
||||
(as a school_admin; class_students upsert → idempotent)
|
||||
|
||||
Idempotent: re-running skips existing auth users and upserts everything else.
|
||||
|
||||
Env required: SUPABASE_URL, SERVICE_ROLE_KEY (API_BASE_URL defaults to api-dev)
|
||||
Optional env: COHORT_COUNT, COHORT_CLASS_CODE, COHORT_INSTITUTE_ID,
|
||||
SEED_STUDENT_PASSWORD, SEED_SCHOOL_ADMIN_PASSWORD
|
||||
|
||||
Run (dev):
|
||||
SUPABASE_URL=... SERVICE_ROLE_KEY=... API_BASE_URL=http://192.168.0.64:18000 \
|
||||
python3 -c "from run.initialization.seed_cohort_9p_ph1 import seed; seed()"
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import requests
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
# Greenfield Academy (the school actually populated on dev .94)
|
||||
GREENFIELD_ID = os.getenv("COHORT_INSTITUTE_ID", "a1b2c3d4-e5f6-7890-abcd-ef1234567890")
|
||||
GREENFIELD_DOMAIN = "greenfieldacademy.test"
|
||||
GREENFIELD_ADMIN_EMAIL = f"admin@{GREENFIELD_DOMAIN}"
|
||||
|
||||
CLASS_CODE = os.getenv("COHORT_CLASS_CODE", "9P/Ph1")
|
||||
COHORT_COUNT = int(os.getenv("COHORT_COUNT", "10"))
|
||||
|
||||
# Realistic-ish names so the results table doesn't read "Pupil 01..10".
|
||||
COHORT_NAMES = [
|
||||
("Amelia", "Clarke"), ("Noah", "Bennett"), ("Olivia", "Foster"), ("Leo", "Hughes"),
|
||||
("Ava", "Patel"), ("Jacob", "Reid"), ("Mia", "Turner"), ("Harry", "Ellis"),
|
||||
("Isla", "Morgan"), ("Oscar", "Khan"), ("Freya", "Walsh"), ("Theo", "Ndlovu"),
|
||||
]
|
||||
|
||||
DEFAULT_STUDENT_PASSWORD = "Student@Cc2025!"
|
||||
DEFAULT_SCHOOL_ADMIN_PASSWORD = "Admin@Cc2025!"
|
||||
|
||||
|
||||
def _ctx() -> Dict[str, str]:
|
||||
return {
|
||||
"supa_url": os.environ["SUPABASE_URL"].rstrip("/"),
|
||||
"service_key": os.environ["SERVICE_ROLE_KEY"],
|
||||
"api_base": os.environ.get("API_BASE_URL", "http://192.168.0.64:18000").rstrip("/"),
|
||||
"student_pw": os.getenv("SEED_STUDENT_PASSWORD", DEFAULT_STUDENT_PASSWORD),
|
||||
"admin_pw": os.getenv("SEED_SCHOOL_ADMIN_PASSWORD", DEFAULT_SCHOOL_ADMIN_PASSWORD),
|
||||
}
|
||||
|
||||
|
||||
def _sb_headers(ctx: Dict[str, str]) -> Dict[str, str]:
|
||||
return {
|
||||
"apikey": ctx["service_key"],
|
||||
"Authorization": f"Bearer {ctx['service_key']}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _sign_in(ctx: Dict[str, str], email: str, password: str) -> str:
|
||||
r = requests.post(
|
||||
f"{ctx['supa_url']}/auth/v1/token?grant_type=password",
|
||||
headers={"apikey": ctx["service_key"], "Content-Type": "application/json"},
|
||||
json={"email": email, "password": password},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def _resolve_class_id(ctx: Dict[str, str]) -> Optional[str]:
|
||||
r = requests.get(
|
||||
f"{ctx['supa_url']}/rest/v1/classes",
|
||||
headers=_sb_headers(ctx),
|
||||
params={"class_code": f"eq.{CLASS_CODE}",
|
||||
"institute_id": f"eq.{GREENFIELD_ID}",
|
||||
"select": "id,name", "limit": "1"},
|
||||
)
|
||||
data = r.json() if r.ok else []
|
||||
return data[0]["id"] if data else None
|
||||
|
||||
|
||||
def _existing_auth_users(ctx: Dict[str, str]) -> Dict[str, str]:
|
||||
r = requests.get(
|
||||
f"{ctx['supa_url']}/auth/v1/admin/users",
|
||||
headers=_sb_headers(ctx), params={"per_page": 200},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return {u["email"]: u["id"] for u in r.json().get("users", [])}
|
||||
|
||||
|
||||
def _create_auth_user(ctx: Dict[str, str], spec: Dict) -> Optional[str]:
|
||||
r = requests.post(
|
||||
f"{ctx['supa_url']}/auth/v1/admin/users",
|
||||
headers=_sb_headers(ctx),
|
||||
json={
|
||||
"email": spec["email"], "password": ctx["student_pw"], "email_confirm": True,
|
||||
"user_metadata": {
|
||||
"username": spec["username"], "full_name": spec["full_name"],
|
||||
"display_name": spec["display_name"], "user_type": "student",
|
||||
},
|
||||
},
|
||||
)
|
||||
if r.status_code in (200, 201):
|
||||
return r.json()["id"]
|
||||
return None
|
||||
|
||||
|
||||
def _upsert(ctx: Dict[str, str], table: str, row: Dict, on_conflict: str) -> bool:
|
||||
h = {**_sb_headers(ctx), "Prefer": "resolution=merge-duplicates,return=minimal"}
|
||||
r = requests.post(f"{ctx['supa_url']}/rest/v1/{table}",
|
||||
headers=h, json=row, params={"on_conflict": on_conflict})
|
||||
return r.ok
|
||||
|
||||
|
||||
def _cohort_specs() -> List[Dict]:
|
||||
specs = []
|
||||
for i in range(1, COHORT_COUNT + 1):
|
||||
first, last = COHORT_NAMES[(i - 1) % len(COHORT_NAMES)]
|
||||
prefix = f"cohort{i:02d}"
|
||||
specs.append({
|
||||
"email": f"{prefix}@{GREENFIELD_DOMAIN}",
|
||||
"username": f"{prefix}.{GREENFIELD_DOMAIN.replace('.', '_')}",
|
||||
"full_name": f"{first} {last}",
|
||||
"display_name": first,
|
||||
})
|
||||
return specs
|
||||
|
||||
|
||||
def seed(count: Optional[int] = None) -> Dict[str, Any]:
|
||||
global COHORT_COUNT
|
||||
if count is not None:
|
||||
COHORT_COUNT = count
|
||||
ctx = _ctx()
|
||||
results: Dict[str, Any] = {"class_code": CLASS_CODE, "requested": COHORT_COUNT,
|
||||
"created": 0, "reused": 0, "enrolled": 0, "errors": []}
|
||||
|
||||
print(f"COHORT SEED → {CLASS_CODE} @ {GREENFIELD_DOMAIN} (target {COHORT_COUNT} students)")
|
||||
|
||||
class_id = _resolve_class_id(ctx)
|
||||
if not class_id:
|
||||
results["errors"].append(f"class {CLASS_CODE} not found for institute {GREENFIELD_ID}")
|
||||
print(f" ✗ {results['errors'][-1]}")
|
||||
return results
|
||||
print(f" class_id = {class_id}")
|
||||
|
||||
existing = _existing_auth_users(ctx)
|
||||
specs = _cohort_specs()
|
||||
|
||||
# 1) accounts: auth user + profile + membership
|
||||
uids: Dict[str, str] = {}
|
||||
for spec in specs:
|
||||
email = spec["email"]
|
||||
uid = existing.get(email)
|
||||
if uid:
|
||||
results["reused"] += 1
|
||||
else:
|
||||
uid = _create_auth_user(ctx, spec)
|
||||
if not uid:
|
||||
results["errors"].append(f"create auth user {email}")
|
||||
print(f" ✗ create {email}")
|
||||
continue
|
||||
results["created"] += 1
|
||||
time.sleep(0.15)
|
||||
uids[email] = uid
|
||||
ok_p = _upsert(ctx, "profiles", {
|
||||
"id": uid, "email": email, "user_type": "student",
|
||||
"username": spec["username"], "full_name": spec["full_name"],
|
||||
"display_name": spec["display_name"], "school_id": GREENFIELD_ID,
|
||||
"neo4j_sync_status": "pending",
|
||||
}, on_conflict="id")
|
||||
ok_m = _upsert(ctx, "institute_memberships", {
|
||||
"profile_id": uid, "institute_id": GREENFIELD_ID, "role": "student", "metadata": {},
|
||||
}, on_conflict="profile_id,institute_id")
|
||||
if not (ok_p and ok_m):
|
||||
results["errors"].append(f"profile/membership {email} (p={ok_p} m={ok_m})")
|
||||
|
||||
# 2) enrol all into the class (via API, as school admin)
|
||||
admin_token = _sign_in(ctx, GREENFIELD_ADMIN_EMAIL, ctx["admin_pw"])
|
||||
for spec in specs:
|
||||
uid = uids.get(spec["email"])
|
||||
if not uid:
|
||||
continue
|
||||
r = requests.post(
|
||||
f"{ctx['api_base']}/database/timetable/classes/{class_id}/students",
|
||||
headers={"Authorization": f"Bearer {admin_token}", "Content-Type": "application/json"},
|
||||
json={"student_id": uid},
|
||||
)
|
||||
body = {}
|
||||
try:
|
||||
body = r.json()
|
||||
except Exception:
|
||||
pass
|
||||
if r.ok and (body.get("status") == "ok" or body.get("row") or body.get("id")):
|
||||
results["enrolled"] += 1
|
||||
print(f" ✓ {spec['email'].split('@')[0]} → {CLASS_CODE}")
|
||||
else:
|
||||
results["errors"].append(f"enrol {spec['email']}: {r.status_code} {str(body)[:120]}")
|
||||
print(f" ✗ enrol {spec['email']}: {r.status_code}")
|
||||
time.sleep(0.1)
|
||||
|
||||
print(f"\nDONE: created {results['created']}, reused {results['reused']}, "
|
||||
f"enrolled {results['enrolled']}/{COHORT_COUNT}, errors {len(results['errors'])}")
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
print(json.dumps(seed(), indent=2, default=str))
|
||||
@@ -0,0 +1,384 @@
|
||||
"""
|
||||
seed_curriculum.py — Create curriculum data: exam board specifications and exams.
|
||||
|
||||
Seeds eb_specifications and eb_exams tables with realistic UK exam board data
|
||||
(AQA, Edexcel, OCR) for Physics, Maths, and Computer Science across both schools.
|
||||
|
||||
Also seeds curriculum_topics in Neo4j for the school databases.
|
||||
|
||||
Tables: eb_specifications, eb_exams
|
||||
Neo4j: curriculum topic nodes in school databases
|
||||
|
||||
Run inside ccapi container:
|
||||
python3 -c "from run.initialization.seed_curriculum import seed; seed()"
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
import requests
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
SUPA_URL = os.environ["SUPABASE_URL"]
|
||||
SERVICE_KEY = os.environ["SERVICE_ROLE_KEY"]
|
||||
API_BASE = os.environ.get("API_BASE_URL", "http://localhost:8000")
|
||||
|
||||
# ─── School constants ────────────────────────────────────────────────────────
|
||||
|
||||
KEVLARAI_ID = "6585bf91-6ae8-4d72-ab54-cddf3ba4e648"
|
||||
GREENFIELD_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
||||
|
||||
# ─── Exam board specifications ───────────────────────────────────────────────
|
||||
# Realistic UK exam board data for the subjects we teach.
|
||||
|
||||
SPECIFICATIONS = [
|
||||
# AQA Physics
|
||||
{
|
||||
"spec_code": "AQA-PHYS-8201",
|
||||
"exam_board_code": "AQA",
|
||||
"award_code": "8201",
|
||||
"subject_code": "PHYSICS",
|
||||
"first_teach": "2016",
|
||||
"spec_ver": "1.3",
|
||||
"storage_loc": "cc.public.snapshots/curriculum/aqa/physics/8201_spec.pdf",
|
||||
"doc_type": "pdf",
|
||||
},
|
||||
{
|
||||
"spec_code": "AQA-PHYS-8203",
|
||||
"exam_board_code": "AQA",
|
||||
"award_code": "8203",
|
||||
"subject_code": "PHYSICS",
|
||||
"first_teach": "2016",
|
||||
"spec_ver": "1.3",
|
||||
"storage_loc": "cc.public.snapshots/curriculum/aqa/physics/8203_spec.pdf",
|
||||
"doc_type": "pdf",
|
||||
},
|
||||
# AQA GCSE Physics 8463 (standalone) — the real spec for the exam-marker test paper
|
||||
# (AQA Physics Paper 1H 2022). Spec graph: cc.public.exams Specification AQA-PHYS-8463.
|
||||
{
|
||||
"spec_code": "AQA-PHYS-8463",
|
||||
"exam_board_code": "AQA",
|
||||
"award_code": "8463",
|
||||
"subject_code": "PHYSICS",
|
||||
"first_teach": "2016",
|
||||
"spec_ver": "1.0",
|
||||
"storage_loc": "cc.examboards/aqa/physics/8463/8463_spec.pdf", # placeholder (no file yet)
|
||||
"doc_type": "pdf",
|
||||
},
|
||||
# Edexcel Maths
|
||||
{
|
||||
"spec_code": "EDX-MATH-1MA1",
|
||||
"exam_board_code": "EDexcel",
|
||||
"award_code": "1MA1",
|
||||
"subject_code": "MATHEMATICS",
|
||||
"first_teach": "2015",
|
||||
"spec_ver": "2.0",
|
||||
"storage_loc": "cc.public.snapshots/curriculum/edexcel/maths/1MA1_spec.pdf",
|
||||
"doc_type": "pdf",
|
||||
},
|
||||
# OCR Maths
|
||||
{
|
||||
"spec_code": "OCR-MATH-FMH1",
|
||||
"exam_board_code": "OCR",
|
||||
"award_code": "FMH1",
|
||||
"subject_code": "MATHEMATICS",
|
||||
"first_teach": "2017",
|
||||
"spec_ver": "1.1",
|
||||
"storage_loc": "cc.public.snapshots/curriculum/ocr/maths/FMH1_spec.pdf",
|
||||
"doc_type": "pdf",
|
||||
},
|
||||
# AQA Computer Science
|
||||
{
|
||||
"spec_code": "AQA-COMP-7516",
|
||||
"exam_board_code": "AQA",
|
||||
"award_code": "7516",
|
||||
"subject_code": "COMPUTER SCIENCE",
|
||||
"first_teach": "2016",
|
||||
"spec_ver": "1.2",
|
||||
"storage_loc": "cc.public.snapshots/curriculum/aqa/cs/7516_spec.pdf",
|
||||
"doc_type": "pdf",
|
||||
},
|
||||
# Edexcel Computer Science
|
||||
{
|
||||
"spec_code": "EDX-COMP-X042",
|
||||
"exam_board_code": "Edexcel",
|
||||
"award_code": "X042",
|
||||
"subject_code": "COMPUTER SCIENCE",
|
||||
"first_teach": "2016",
|
||||
"spec_ver": "1.0",
|
||||
"storage_loc": "cc.public.snapshots/curriculum/edexcel/cs/X042_spec.pdf",
|
||||
"doc_type": "pdf",
|
||||
},
|
||||
]
|
||||
|
||||
# ─── Exam papers ─────────────────────────────────────────────────────────────
|
||||
# Realistic exam paper references linked to specifications.
|
||||
|
||||
EXAMS = [
|
||||
# AQA GCSE Physics 8463/1 Higher — the exam-marker test paper (real PDF uploaded to
|
||||
# cc.examboards). Join key for cc.public.exams ExamPaper.exam_code.
|
||||
{"exam_code": "AQA-PHYS-8463-1H-22-JUN", "spec_code": "AQA-PHYS-8463", "paper_code": "8463/1",
|
||||
"tier": "higher", "session": "June", "type_code": "QP",
|
||||
"storage_loc": "cc.examboards/aqa/physics/8463/AQA-PHYS-8463-1H-22-JUN.pdf"},
|
||||
|
||||
# AQA Physics 8201/1 (Foundation)
|
||||
{"exam_code": "AQA-PHYS-8201-1-23-JUN", "spec_code": "AQA-PHYS-8201", "paper_code": "8201/1",
|
||||
"tier": "foundation", "session": "June", "type_code": "QP"},
|
||||
{"exam_code": "AQA-PHYS-8201-MS-23-JUN", "spec_code": "AQA-PHYS-8201", "paper_code": "8201/1",
|
||||
"tier": "foundation", "session": "June", "type_code": "MS"},
|
||||
{"exam_code": "AQA-PHYS-8201-ER-23-JUN", "spec_code": "AQA-PHYS-8201", "paper_code": "8201/1",
|
||||
"tier": "foundation", "session": "June", "type_code": "ER"},
|
||||
|
||||
# AQA Physics 8201/2 (Higher)
|
||||
{"exam_code": "AQA-PHYS-8201-2-23-JUN", "spec_code": "AQA-PHYS-8201", "paper_code": "8201/2",
|
||||
"tier": "higher", "session": "June", "type_code": "QP"},
|
||||
{"exam_code": "AQA-PHYS-8201-MS-23-JUN-H", "spec_code": "AQA-PHYS-8201", "paper_code": "8201/2",
|
||||
"tier": "higher", "session": "June", "type_code": "MS"},
|
||||
|
||||
# Edexcel Maths 1MA1/1 (Foundation)
|
||||
{"exam_code": "EDX-MATH-1MA1-1-24-JUN", "spec_code": "EDX-MATH-1MA1", "paper_code": "1MA1/1F",
|
||||
"tier": "foundation", "session": "June", "type_code": "QP"},
|
||||
{"exam_code": "EDX-MATH-1MA1-MS-24-JUN", "spec_code": "EDX-MATH-1MA1", "paper_code": "1MA1/1F",
|
||||
"tier": "foundation", "session": "June", "type_code": "MS"},
|
||||
|
||||
# Edexcel Maths 1MA1/2 (Higher)
|
||||
{"exam_code": "EDX-MATH-1MA1-2-24-JUN", "spec_code": "EDX-MATH-1MA1", "paper_code": "1MA1/2H",
|
||||
"tier": "higher", "session": "June", "type_code": "QP"},
|
||||
{"exam_code": "EDX-MATH-1MA1-MS-24-JUN-H", "spec_code": "EDX-MATH-1MA1", "paper_code": "1MA1/2H",
|
||||
"tier": "higher", "session": "June", "type_code": "MS"},
|
||||
|
||||
# OCR Maths FMH1/1
|
||||
{"exam_code": "OCR-MATH-FMH1-1-24-JUN", "spec_code": "OCR-MATH-FMH1", "paper_code": "FMH1/1",
|
||||
"tier": "higher", "session": "June", "type_code": "QP"},
|
||||
{"exam_code": "OCR-MATH-FMH1-MS-24-JUN", "spec_code": "OCR-MATH-FMH1", "paper_code": "FMH1/1",
|
||||
"tier": "higher", "session": "June", "type_code": "MS"},
|
||||
|
||||
# AQA CS 7516/1
|
||||
{"exam_code": "AQA-COMP-7516-1-23-JUN", "spec_code": "AQA-COMP-7516", "paper_code": "7516/1",
|
||||
"tier": None, "session": "June", "type_code": "QP"},
|
||||
{"exam_code": "AQA-COMP-7516-MS-23-JUN", "spec_code": "AQA-COMP-7516", "paper_code": "7516/1",
|
||||
"tier": None, "session": "June", "type_code": "MS"},
|
||||
|
||||
# AQA CS 7516/2
|
||||
{"exam_code": "AQA-COMP-7516-2-23-JUN", "spec_code": "AQA-COMP-7516", "paper_code": "7516/2",
|
||||
"tier": None, "session": "June", "type_code": "QP"},
|
||||
{"exam_code": "AQA-COMP-7516-ER-23-JUN", "spec_code": "AQA-COMP-7516", "paper_code": "7516/2",
|
||||
"tier": None, "session": "June", "type_code": "ER"},
|
||||
]
|
||||
|
||||
|
||||
# ─── Neo4j curriculum topics ─────────────────────────────────────────────────
|
||||
# Curriculum topics stored in Neo4j school databases (not Supabase).
|
||||
|
||||
CURRICULUM_TOPICS = {
|
||||
"Physics": [
|
||||
{"topic_code": "PHYS-KS3-01", "title": "Forces", "year_group": "9", "key_stage": "3",
|
||||
"description": "Contact and non-contact forces, resultant forces, moments"},
|
||||
{"topic_code": "PHYS-KS3-02", "title": "Energy", "year_group": "9", "key_stage": "3",
|
||||
"description": "Energy stores, transfers, conservation, dissipation"},
|
||||
{"topic_code": "PHYS-KS3-03", "title": "Waves", "year_group": "9", "key_stage": "3",
|
||||
"description": "Transverse and longitudinal waves, reflection, refraction, diffraction"},
|
||||
{"topic_code": "PHYS-KS4-01", "title": "Electricity", "year_group": "10", "key_stage": "4",
|
||||
"description": "Circuits, current, potential difference, resistance, power"},
|
||||
{"topic_code": "PHYS-KS4-02", "title": "Magnetism and Electromagnetism", "year_group": "10", "key_stage": "4",
|
||||
"description": "Magnetic fields, electromagnets, motors, generators"},
|
||||
{"topic_code": "PHYS-KS4-03", "title": "Atomic Structure", "year_group": "10", "key_stage": "4",
|
||||
"description": "Atoms, isotopes, radioactivity, half-life"},
|
||||
{"topic_code": "PHYS-KS4-04", "title": "Particle Physics", "year_group": "11", "key_stage": "4",
|
||||
"description": "Standard model, quarks, leptons, bosons"},
|
||||
{"topic_code": "PHYS-KS4-05", "title": "Cosmology", "year_group": "11", "key_stage": "4",
|
||||
"description": "Big Bang, stellar evolution, redshift"},
|
||||
],
|
||||
"Mathematics": [
|
||||
{"topic_code": "MATH-KS3-01", "title": "Number", "year_group": "9", "key_stage": "3",
|
||||
"description": "Integers, fractions, decimals, percentages, ratio, proportion"},
|
||||
{"topic_code": "MATH-KS3-02", "title": "Algebra", "year_group": "9", "key_stage": "3",
|
||||
"description": "Expressions, equations, inequalities, sequences"},
|
||||
{"topic_code": "MATH-KS3-03", "title": "Geometry", "year_group": "9", "key_stage": "3",
|
||||
"description": "Angles, polygons, circles, transformations, constructions"},
|
||||
{"topic_code": "MATH-KS4-01", "title": "Number and Algebra", "year_group": "10", "key_stage": "4",
|
||||
"description": "Surds, indices, standard form, expanding brackets, factorising"},
|
||||
{"topic_code": "MATH-KS4-02", "title": "Graphs and Functions", "year_group": "10", "key_stage": "4",
|
||||
"description": "Linear, quadratic, cubic graphs, gradients, intercepts"},
|
||||
{"topic_code": "MATH-KS4-03", "title": "Statistics and Probability", "year_group": "10", "key_stage": "4",
|
||||
"description": "Data types, charts, expected frequency, tree diagrams, two-way tables"},
|
||||
{"topic_code": "MATH-KS4-04", "title": "Geometry and Measures", "year_group": "10", "key_stage": "4",
|
||||
"description": "Area, volume, surface area, Pythagoras, trigonometry, bearings"},
|
||||
{"topic_code": "MATH-KS4-05", "title": "Simultaneous Equations and Quadratics", "year_group": "11", "key_stage": "4",
|
||||
"description": "Solving simultaneous equations, completing the square, quadratic formula"},
|
||||
],
|
||||
"Computer Science": [
|
||||
{"topic_code": "CS-KS4-01", "title": "Data Representation", "year_group": "10", "key_stage": "4",
|
||||
"description": "Binary, hexadecimal, bit operations, compression, encryption"},
|
||||
{"topic_code": "CS-KS4-02", "title": "Computer Systems", "year_group": "10", "key_stage": "4",
|
||||
"description": "CPU architecture, memory, storage, networks, topologies"},
|
||||
{"topic_code": "CS-KS4-03", "title": "Algorithms and Programming", "year_group": "10", "key_stage": "4",
|
||||
"description": "Algorithms, flowcharts, pseudocode, debugging, testing"},
|
||||
{"topic_code": "CS-KS4-04", "title": "Data Types and Structures", "year_group": "11", "key_stage": "4",
|
||||
"description": "Strings, arrays, lists, records, 2D arrays"},
|
||||
{"topic_code": "CS-KS4-05", "title": "Boolean Logic and Search", "year_group": "11", "key_stage": "4",
|
||||
"description": "Boolean operators, linear search, binary search, sorting"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sb_headers() -> Dict:
|
||||
return {
|
||||
"apikey": SERVICE_KEY,
|
||||
"Authorization": f"Bearer {SERVICE_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _sign_in(email: str, password: str) -> str:
|
||||
r = requests.post(
|
||||
f"{SUPA_URL}/auth/v1/token?grant_type=password",
|
||||
headers={"apikey": SERVICE_KEY, "Content-Type": "application/json"},
|
||||
json={"email": email, "password": password},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
# ─── Main seed ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def seed() -> Dict[str, Any]:
|
||||
print("=" * 60)
|
||||
print("Curriculum seed — exam board specs and exams")
|
||||
print("=" * 60)
|
||||
results: Dict[str, Any] = {}
|
||||
errors: List[str] = []
|
||||
|
||||
# ── [1] Seed eb_specifications ──────────────────────────────────────────
|
||||
print("\n[1] Seeding exam board specifications...")
|
||||
specs_created = 0
|
||||
specs_skipped = 0
|
||||
|
||||
for spec in SPECIFICATIONS:
|
||||
r = requests.post(
|
||||
f"{SUPA_URL}/rest/v1/eb_specifications",
|
||||
headers={**_sb_headers(), "Prefer": "return=representation"},
|
||||
json={
|
||||
**spec,
|
||||
"id": str(uuid.uuid4()),
|
||||
"doc_details": {},
|
||||
"docling_docs": {},
|
||||
},
|
||||
params={"on_conflict": "spec_code"},
|
||||
)
|
||||
if r.status_code in (200, 201):
|
||||
specs_created += 1
|
||||
print(f" ✓ {spec['spec_code']} ({spec['exam_board_code']}/{spec['subject_code']})")
|
||||
elif r.status_code == 409:
|
||||
specs_skipped += 1
|
||||
print(f" ~ SKIP (exists): {spec['spec_code']}")
|
||||
else:
|
||||
err = f"spec {spec['spec_code']}: {r.status_code} {r.text[:100]}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
|
||||
results["specifications"] = {"created": specs_created, "skipped": specs_skipped}
|
||||
|
||||
# ── [2] Seed eb_exams ───────────────────────────────────────────────────
|
||||
print("\n[2] Seeding exam papers...")
|
||||
exams_created = 0
|
||||
exams_skipped = 0
|
||||
|
||||
for exam in EXAMS:
|
||||
r = requests.post(
|
||||
f"{SUPA_URL}/rest/v1/eb_exams",
|
||||
headers={**_sb_headers(), "Prefer": "return=representation"},
|
||||
json={
|
||||
**exam,
|
||||
"id": str(uuid.uuid4()),
|
||||
"doc_details": {},
|
||||
"docling_docs": {},
|
||||
},
|
||||
params={"on_conflict": "exam_code"},
|
||||
)
|
||||
if r.status_code in (200, 201):
|
||||
exams_created += 1
|
||||
print(f" ✓ {exam['exam_code']} ({exam['type_code']})")
|
||||
elif r.status_code == 409:
|
||||
exams_skipped += 1
|
||||
print(f" ~ SKIP (exists): {exam['exam_code']}")
|
||||
else:
|
||||
err = f"exam {exam['exam_code']}: {r.status_code} {r.text[:100]}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
|
||||
results["exams"] = {"created": exams_created, "skipped": exams_skipped}
|
||||
|
||||
# ── [3] Seed Neo4j curriculum topics ────────────────────────────────────
|
||||
print("\n[3] Seeding Neo4j curriculum topics...")
|
||||
try:
|
||||
from neo4j import GraphDatabase
|
||||
driver = GraphDatabase.driver("bolt://192.168.0.209:7687", auth=("neo4j", "&%N304j&%"))
|
||||
|
||||
topics_created = 0
|
||||
topics_skipped = 0
|
||||
|
||||
for school_id, school_name in [(KEVLARAI_ID, "KevlarAI"), (GREENFIELD_ID, "Greenfield Academy")]:
|
||||
db_name = f"cc.institutes.{school_id.replace('-', '')}"
|
||||
print(f"\n [{school_name}] -> {db_name}")
|
||||
|
||||
with driver.session(database=db_name) as s:
|
||||
for subject, topics in CURRICULUM_TOPICS.items():
|
||||
# Create subject node
|
||||
s.run(
|
||||
"MERGE (s:Subject {code: $subject}) "
|
||||
"SET s.title = $title, s.school_id = $school_id",
|
||||
subject=subject, title=subject, school_id=school_id,
|
||||
)
|
||||
|
||||
for topic in topics:
|
||||
result = s.run(
|
||||
"MERGE (t:CurriculumTopic {code: $code}) "
|
||||
"SET t.title = $title, "
|
||||
" t.year_group = $year_group, "
|
||||
" t.key_stage = $key_stage, "
|
||||
" t.description = $description, "
|
||||
" t.subject_code = $subject, "
|
||||
" t.school_id = $school_id "
|
||||
"MERGE (s:Subject {code: $subject}) "
|
||||
"MERGE (s)-[:CONTAINS_TOPIC]->(t)",
|
||||
code=topic["topic_code"],
|
||||
title=topic["title"],
|
||||
year_group=topic["year_group"],
|
||||
key_stage=topic["key_stage"],
|
||||
description=topic["description"],
|
||||
subject=subject,
|
||||
school_id=school_id,
|
||||
)
|
||||
# Check if it was created or matched
|
||||
topics_created += 1
|
||||
|
||||
print(f" ✓ {school_name}: {len(CURRICULUM_TOPICS) * len(list(CURRICULUM_TOPICS.values())[0])} topic nodes")
|
||||
|
||||
driver.close()
|
||||
results["neo4j_topics"] = {"created": topics_created}
|
||||
|
||||
except Exception as e:
|
||||
err = f"neo4j_topics: {e}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["neo4j_topics"] = {"error": str(e)}
|
||||
|
||||
# ── Summary ─────────────────────────────────────────────────────────────
|
||||
print("\n" + "=" * 60)
|
||||
results["success"] = len(errors) == 0
|
||||
results["errors"] = errors
|
||||
print(f"COMPLETE — {specs_created} specs, {exams_created} exams, "
|
||||
f"{results.get('neo4j_topics', {}).get('created', '?')} topics")
|
||||
if errors:
|
||||
print(f"Errors ({len(errors)}):")
|
||||
for e in errors:
|
||||
print(f" ✗ {e}")
|
||||
print("=" * 60)
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
print(json.dumps(seed(), indent=2, default=str))
|
||||
@@ -0,0 +1,531 @@
|
||||
"""
|
||||
seed_environment.py — idempotent full-environment rebuild.
|
||||
|
||||
Assumes reset_environment.py has already been run (or it's the first boot).
|
||||
Safe to re-run: all writes use UPSERT / MERGE.
|
||||
|
||||
Schools
|
||||
-------
|
||||
KevlarAI 6585bf91-6ae8-4d72-ab54-cddf3ba4e648 kevlarai.test
|
||||
Greenfield Academy a1b2c3d4-e5f6-7890-abcd-ef1234567890 greenfieldacademy.test
|
||||
|
||||
Uniform accounts per school (10 × 2 = 20 total)
|
||||
------------------------------------------------
|
||||
admin@{domain} school_admin
|
||||
head@{domain} school_admin
|
||||
physics@{domain} teacher
|
||||
maths@{domain} teacher
|
||||
teacher1@{domain} teacher
|
||||
teacher2@{domain} teacher
|
||||
teacher3@{domain} teacher
|
||||
student1@{domain} student
|
||||
student2@{domain} student
|
||||
student3@{domain} student
|
||||
|
||||
Run from inside the ccapi container:
|
||||
python3 main.py --mode seed
|
||||
python3 main.py --mode seed-test
|
||||
|
||||
Or directly:
|
||||
python3 -c "from run.initialization.seed_environment import seed; seed()"
|
||||
python3 -c "from run.initialization.seed_environment import seed; seed(test=True)"
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from modules.logger_tool import initialise_logger
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
|
||||
|
||||
# ─── School constants ─────────────────────────────────────────────────────────
|
||||
|
||||
KEVLARAI_ID = "6585bf91-6ae8-4d72-ab54-cddf3ba4e648"
|
||||
KEVLARAI_NAME = "KevlarAI"
|
||||
KEVLARAI_URN = "KEVLARAI-001"
|
||||
KEVLARAI_DOMAIN = "kevlarai.test"
|
||||
|
||||
GREENFIELD_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
||||
GREENFIELD_NAME = "Greenfield Academy"
|
||||
GREENFIELD_URN = "TEST-GFA-001"
|
||||
GREENFIELD_DOMAIN = "greenfieldacademy.test"
|
||||
|
||||
# ─── Passwords ────────────────────────────────────────────────────────────────
|
||||
|
||||
DEFAULT_PLATFORM_ADMIN_PASSWORD = "KevlarAI2025!"
|
||||
DEFAULT_SCHOOL_ADMIN_PASSWORD = "Admin@Cc2025!"
|
||||
DEFAULT_TEACHER_PASSWORD = "Teacher@Cc2025!"
|
||||
DEFAULT_STUDENT_PASSWORD = "Student@Cc2025!"
|
||||
|
||||
|
||||
def get_seed_password(role: str) -> str:
|
||||
"""Return the seed password for a role, allowing env overrides."""
|
||||
normalized = role.lower().strip()
|
||||
env_by_role = {
|
||||
"platform_admin": ("SEED_PLATFORM_ADMIN_PASSWORD", DEFAULT_PLATFORM_ADMIN_PASSWORD),
|
||||
"school_admin": ("SEED_SCHOOL_ADMIN_PASSWORD", DEFAULT_SCHOOL_ADMIN_PASSWORD),
|
||||
"teacher": ("SEED_TEACHER_PASSWORD", DEFAULT_TEACHER_PASSWORD),
|
||||
"student": ("SEED_STUDENT_PASSWORD", DEFAULT_STUDENT_PASSWORD),
|
||||
}
|
||||
if normalized not in env_by_role:
|
||||
raise ValueError(f"Unknown seed password role: {role}")
|
||||
env_name, default = env_by_role[normalized]
|
||||
return os.getenv(env_name, default)
|
||||
|
||||
|
||||
def get_seed_passwords() -> Dict[str, str]:
|
||||
return {
|
||||
"platform_admin": get_seed_password("platform_admin"),
|
||||
"school_admin": get_seed_password("school_admin"),
|
||||
"teacher": get_seed_password("teacher"),
|
||||
"student": get_seed_password("student"),
|
||||
}
|
||||
|
||||
|
||||
# ─── Account template ────────────────────────────────────────────────────────
|
||||
|
||||
def _school_accounts(domain: str, institute_id: str) -> List[Dict]:
|
||||
passwords = get_seed_passwords()
|
||||
return [
|
||||
# school_admin accounts
|
||||
{
|
||||
"prefix": "admin", "email": f"admin@{domain}",
|
||||
"full_name": "Alex Admin", "display_name": "Alex",
|
||||
"username": f"admin.{domain.replace('.', '_')}",
|
||||
"user_type": "teacher", "role": "school_admin", "password": passwords["school_admin"],
|
||||
"institute_id": institute_id,
|
||||
},
|
||||
{
|
||||
"prefix": "head", "email": f"head@{domain}",
|
||||
"full_name": "Helen Head", "display_name": "Helen",
|
||||
"username": f"head.{domain.replace('.', '_')}",
|
||||
"user_type": "teacher", "role": "school_admin", "password": passwords["school_admin"],
|
||||
"institute_id": institute_id,
|
||||
},
|
||||
# teacher accounts
|
||||
{
|
||||
"prefix": "physics", "email": f"physics@{domain}",
|
||||
"full_name": "Phil Physics", "display_name": "Phil",
|
||||
"username": f"physics.{domain.replace('.', '_')}",
|
||||
"user_type": "teacher", "role": "teacher", "password": passwords["teacher"],
|
||||
"institute_id": institute_id,
|
||||
},
|
||||
{
|
||||
"prefix": "maths", "email": f"maths@{domain}",
|
||||
"full_name": "Mary Maths", "display_name": "Mary",
|
||||
"username": f"maths.{domain.replace('.', '_')}",
|
||||
"user_type": "teacher", "role": "teacher", "password": passwords["teacher"],
|
||||
"institute_id": institute_id,
|
||||
},
|
||||
{
|
||||
"prefix": "teacher1", "email": f"teacher1@{domain}",
|
||||
"full_name": "Tom Teacher", "display_name": "Tom",
|
||||
"username": f"teacher1.{domain.replace('.', '_')}",
|
||||
"user_type": "teacher", "role": "teacher", "password": passwords["teacher"],
|
||||
"institute_id": institute_id,
|
||||
},
|
||||
{
|
||||
"prefix": "teacher2", "email": f"teacher2@{domain}",
|
||||
"full_name": "Tara Teach", "display_name": "Tara",
|
||||
"username": f"teacher2.{domain.replace('.', '_')}",
|
||||
"user_type": "teacher", "role": "teacher", "password": passwords["teacher"],
|
||||
"institute_id": institute_id,
|
||||
},
|
||||
{
|
||||
"prefix": "teacher3", "email": f"teacher3@{domain}",
|
||||
"full_name": "Tim Teachwell", "display_name": "Tim",
|
||||
"username": f"teacher3.{domain.replace('.', '_')}",
|
||||
"user_type": "teacher", "role": "teacher", "password": passwords["teacher"],
|
||||
"institute_id": institute_id,
|
||||
},
|
||||
# student accounts
|
||||
{
|
||||
"prefix": "student1", "email": f"student1@{domain}",
|
||||
"full_name": "Sam Student", "display_name": "Sam",
|
||||
"username": f"student1.{domain.replace('.', '_')}",
|
||||
"user_type": "student", "role": "student", "password": passwords["student"],
|
||||
"institute_id": institute_id,
|
||||
},
|
||||
{
|
||||
"prefix": "student2", "email": f"student2@{domain}",
|
||||
"full_name": "Sophie Study", "display_name": "Sophie",
|
||||
"username": f"student2.{domain.replace('.', '_')}",
|
||||
"user_type": "student", "role": "student", "password": passwords["student"],
|
||||
"institute_id": institute_id,
|
||||
},
|
||||
{
|
||||
"prefix": "student3", "email": f"student3@{domain}",
|
||||
"full_name": "Steve Scholar", "display_name": "Steve",
|
||||
"username": f"student3.{domain.replace('.', '_')}",
|
||||
"user_type": "student", "role": "student", "password": passwords["student"],
|
||||
"institute_id": institute_id,
|
||||
},
|
||||
]
|
||||
|
||||
FULL_ACCOUNTS = (
|
||||
_school_accounts(KEVLARAI_DOMAIN, KEVLARAI_ID) +
|
||||
_school_accounts(GREENFIELD_DOMAIN, GREENFIELD_ID)
|
||||
)
|
||||
|
||||
|
||||
def get_accounts(test: bool = False) -> List[Dict]:
|
||||
"""Return full (20-user) or lightweight test (9-user) seed fixtures."""
|
||||
if not test:
|
||||
return list(FULL_ACCOUNTS)
|
||||
|
||||
wanted = {
|
||||
f"student1@{KEVLARAI_DOMAIN}",
|
||||
f"student2@{KEVLARAI_DOMAIN}",
|
||||
f"student3@{KEVLARAI_DOMAIN}",
|
||||
f"admin@{GREENFIELD_DOMAIN}",
|
||||
f"physics@{GREENFIELD_DOMAIN}",
|
||||
f"maths@{GREENFIELD_DOMAIN}",
|
||||
f"teacher1@{GREENFIELD_DOMAIN}",
|
||||
f"student1@{GREENFIELD_DOMAIN}",
|
||||
f"student2@{GREENFIELD_DOMAIN}",
|
||||
}
|
||||
return [account for account in FULL_ACCOUNTS if account["email"] in wanted]
|
||||
|
||||
# ─── Supabase helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
def _sb_ctx():
|
||||
url = os.environ["SUPABASE_URL"]
|
||||
key = os.environ["SERVICE_ROLE_KEY"]
|
||||
headers = {
|
||||
"apikey": key,
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return url, headers
|
||||
|
||||
|
||||
def _auth_post(url, headers, path, data):
|
||||
return requests.post(f"{url}/auth/v1/admin{path}", headers=headers, json=data)
|
||||
|
||||
|
||||
def _auth_get(url, headers, path, params=None):
|
||||
r = requests.get(f"{url}/auth/v1/admin{path}", headers=headers, params=params)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def _rest_upsert(url, headers, table, data, on_conflict):
|
||||
h = {**headers, "Prefer": "resolution=merge-duplicates,return=representation"}
|
||||
r = requests.post(
|
||||
f"{url}/rest/v1/{table}",
|
||||
headers=h,
|
||||
json=data,
|
||||
params={"on_conflict": on_conflict},
|
||||
)
|
||||
return r
|
||||
|
||||
|
||||
def _rest_patch(url, headers, table, match_col, match_val, data):
|
||||
r = requests.patch(
|
||||
f"{url}/rest/v1/{table}",
|
||||
headers={**headers, "Prefer": "return=minimal"},
|
||||
params={match_col: f"eq.{match_val}"},
|
||||
json=data,
|
||||
)
|
||||
return r
|
||||
|
||||
|
||||
# ─── Main seed function ───────────────────────────────────────────────────────
|
||||
|
||||
def seed(test: bool = False) -> Dict[str, Any]:
|
||||
from modules.database.services.provisioning_service import ProvisioningService
|
||||
from modules.database.services.neo4j_service import Neo4jService
|
||||
from modules.database.init.init_calendar import create_calendar
|
||||
|
||||
accounts = get_accounts(test=test)
|
||||
url, headers = _sb_ctx()
|
||||
errors: List[str] = []
|
||||
results: Dict[str, Any] = {"mode": "test" if test else "full", "account_count": len(accounts)}
|
||||
|
||||
# ── Step 1: Fix KevlarAI institute record ─────────────────────────────────
|
||||
logger.info("=" * 60)
|
||||
logger.info(f"SEED ENVIRONMENT ({'test' if test else 'full'} mode)")
|
||||
logger.info("=" * 60)
|
||||
logger.info("\n[1] KevlarAI institute record...")
|
||||
try:
|
||||
r = _rest_upsert(url, headers, "institutes", {
|
||||
"id": KEVLARAI_ID,
|
||||
"name": KEVLARAI_NAME,
|
||||
"urn": KEVLARAI_URN,
|
||||
"status": "active",
|
||||
"website": "https://kevlarai.test",
|
||||
"address": {"line1": "1 AI Lane", "city": "London", "postcode": "EC1A 1BB"},
|
||||
"metadata": {"headteacher": "Alex Admin", "seeded": True},
|
||||
}, on_conflict="id")
|
||||
if r.status_code in (200, 201):
|
||||
logger.info(" KevlarAI upserted ✓")
|
||||
else:
|
||||
raise Exception(r.text[:200])
|
||||
except Exception as e:
|
||||
errors.append(f"kevlarai_institute: {e}")
|
||||
logger.error(f" {e}")
|
||||
|
||||
# ── Step 2: Create Greenfield Academy if needed ───────────────────────────
|
||||
logger.info("[2] Greenfield Academy institute record...")
|
||||
try:
|
||||
neo4j_uuid_greenfield = GREENFIELD_ID.replace("-", "")
|
||||
r = _rest_upsert(url, headers, "institutes", {
|
||||
"id": GREENFIELD_ID,
|
||||
"name": GREENFIELD_NAME,
|
||||
"urn": GREENFIELD_URN,
|
||||
"status": "active",
|
||||
"website": "https://greenfieldacademy.test",
|
||||
"address": {"line1": "1 Academy Road", "city": "Testville", "postcode": "TE1 1ST"},
|
||||
"metadata": {"headteacher": "Alex Admin", "seeded": True},
|
||||
"neo4j_uuid_string": neo4j_uuid_greenfield,
|
||||
}, on_conflict="id")
|
||||
if r.status_code in (200, 201):
|
||||
logger.info(" Greenfield Academy upserted ✓")
|
||||
else:
|
||||
raise Exception(r.text[:200])
|
||||
except Exception as e:
|
||||
errors.append(f"greenfield_institute: {e}")
|
||||
logger.error(f" {e}")
|
||||
|
||||
# ── Step 3: Provision Neo4j for both schools ──────────────────────────────
|
||||
logger.info("[3] Neo4j school provisioning...")
|
||||
provisioner = ProvisioningService()
|
||||
school_dbs: Dict[str, str] = {}
|
||||
|
||||
for iid, name in [(KEVLARAI_ID, "KevlarAI"), (GREENFIELD_ID, "Greenfield Academy")]:
|
||||
try:
|
||||
result = provisioner.ensure_school(iid)
|
||||
db = result["db_name"]
|
||||
school_dbs[iid] = db
|
||||
logger.info(f" {name}: {db} ✓")
|
||||
except Exception as e:
|
||||
errors.append(f"ensure_school {name}: {e}")
|
||||
logger.error(f" {name}: {e}")
|
||||
# derive fallback db name
|
||||
school_dbs[iid] = f"cc.institutes.{iid.replace('-', '')}"
|
||||
|
||||
# ── Step 4: Rebuild classroomcopilot global calendar ─────────────────────
|
||||
logger.info("[4] classroomcopilot global calendar (2024–2028)...")
|
||||
try:
|
||||
neo4j_svc = Neo4jService()
|
||||
neo4j_svc.create_database("classroomcopilot")
|
||||
logger.info(" DB created, waiting 5s for availability...")
|
||||
time.sleep(5)
|
||||
start_dt = datetime(2024, 1, 1)
|
||||
end_dt = datetime(2028, 12, 31)
|
||||
create_calendar("classroomcopilot", start_dt, end_dt)
|
||||
logger.info(" Calendar built ✓")
|
||||
results["global_calendar"] = "ok"
|
||||
except Exception as e:
|
||||
errors.append(f"global_calendar: {e}")
|
||||
logger.error(f" {e}")
|
||||
results["global_calendar"] = "error"
|
||||
|
||||
# ── Step 5: Create / verify auth users ────────────────────────────────────
|
||||
logger.info(f"[5] Creating auth users ({len(accounts)} accounts)...")
|
||||
try:
|
||||
existing = _auth_get(url, headers, "/users", {"per_page": 200}).get("users", [])
|
||||
existing_by_email = {u["email"]: u for u in existing}
|
||||
except Exception as e:
|
||||
errors.append(f"list_auth_users: {e}")
|
||||
existing_by_email = {}
|
||||
|
||||
created_users: Dict[str, str] = {} # email → uid
|
||||
for spec in accounts:
|
||||
email = spec["email"]
|
||||
if email in existing_by_email:
|
||||
created_users[email] = existing_by_email[email]["id"]
|
||||
logger.info(f" {email}: exists [{created_users[email][:8]}]")
|
||||
continue
|
||||
r = _auth_post(url, headers, "/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"]
|
||||
created_users[email] = uid
|
||||
logger.info(f" {email}: created [{uid[:8]}]")
|
||||
else:
|
||||
errors.append(f"create {email}: {r.text[:150]}")
|
||||
logger.error(f" {email}: {r.text[:150]}")
|
||||
time.sleep(0.2)
|
||||
|
||||
results["users_created"] = len(created_users)
|
||||
|
||||
# ── Step 6: Upsert profiles and memberships ───────────────────────────────
|
||||
logger.info("[6] Upserting profiles and memberships...")
|
||||
for spec in accounts:
|
||||
uid = created_users.get(spec["email"])
|
||||
if not uid:
|
||||
continue
|
||||
try:
|
||||
_rest_upsert(url, headers, "profiles", {
|
||||
"id": uid,
|
||||
"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")
|
||||
_rest_upsert(url, headers, "institute_memberships", {
|
||||
"profile_id": uid,
|
||||
"institute_id": spec["institute_id"],
|
||||
"role": spec["role"],
|
||||
"metadata": {},
|
||||
}, on_conflict="profile_id,institute_id")
|
||||
except Exception as e:
|
||||
errors.append(f"profile/membership {spec['email']}: {e}")
|
||||
logger.error(f" {spec['email']}: {e}")
|
||||
|
||||
logger.info(" Profiles and memberships upserted ✓")
|
||||
|
||||
# ── Step 7: Merge Neo4j Teacher/Student nodes ─────────────────────────────
|
||||
logger.info("[7] Merging Neo4j worker nodes...")
|
||||
try:
|
||||
from modules.database.tools.neo4j_driver_tools import close_driver, get_driver
|
||||
bolt_url = os.getenv("NEO4J_BOLT_URL") or os.getenv("APP_BOLT_URL")
|
||||
neo4j_user = os.getenv("NEO4J_USER") or os.getenv("USER_NEO4J")
|
||||
neo4j_password = os.getenv("NEO4J_PASSWORD") or os.getenv("PASSWORD_NEO4J")
|
||||
auth = (neo4j_user, neo4j_password) if neo4j_user and neo4j_password else None
|
||||
driver = get_driver(url=bolt_url, auth=auth) if bolt_url else get_driver()
|
||||
if driver is None:
|
||||
raise RuntimeError("Neo4j driver unavailable; check NEO4J_BOLT_URL/APP_BOLT_URL and NEO4J_PASSWORD/PASSWORD_NEO4J")
|
||||
|
||||
# Group by institute DB
|
||||
by_db: Dict[str, List[Dict]] = {}
|
||||
for spec in accounts:
|
||||
uid = created_users.get(spec["email"])
|
||||
if not uid:
|
||||
continue
|
||||
db = school_dbs.get(spec["institute_id"])
|
||||
if not db:
|
||||
continue
|
||||
by_db.setdefault(db, []).append({**spec, "uid": uid})
|
||||
|
||||
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.worker_type = $utype",
|
||||
uid=u["uid"], email=u["email"],
|
||||
name=u["full_name"], utype=u["user_type"],
|
||||
)
|
||||
logger.info(f" [{db[:35]}] {len(users)} nodes merged ✓")
|
||||
|
||||
close_driver(driver)
|
||||
results["neo4j_nodes"] = "ok"
|
||||
except Exception as e:
|
||||
errors.append(f"neo4j_nodes: {e}")
|
||||
logger.error(f" {e}")
|
||||
results["neo4j_nodes"] = "error"
|
||||
|
||||
# ── Ensure kcar is a platform super-admin ─────────────────────────────────
|
||||
logger.info("[8] Ensuring kcar platform admin record...")
|
||||
KCAR_ID = "d9e1d1a9-04c4-4611-bb05-57babf4a9a28"
|
||||
try:
|
||||
_rest_upsert(url, headers, "admin_profiles", {
|
||||
"id": KCAR_ID,
|
||||
"email": "[email protected]",
|
||||
"display_name": "Kevin Carroll",
|
||||
"admin_role": "super_admin",
|
||||
"is_super_admin": True,
|
||||
"metadata": {"seeded": True},
|
||||
}, on_conflict="id")
|
||||
logger.info(" kcar → admin_profiles ✓")
|
||||
except Exception as e:
|
||||
errors.append(f"kcar_admin: {e}")
|
||||
logger.error(f" {e}")
|
||||
|
||||
# Fix kcar's auth user_metadata and profiles.user_type to "platform_admin".
|
||||
# Without this, POST /user/init assigns kcar to the default school on first login.
|
||||
try:
|
||||
r = requests.put(
|
||||
f"{url}/auth/v1/admin/users/{KCAR_ID}",
|
||||
headers=headers,
|
||||
json={"user_metadata": {"user_type": "platform_admin"}},
|
||||
)
|
||||
if r.status_code in (200, 201):
|
||||
logger.info(" kcar → auth user_metadata: platform_admin ✓")
|
||||
else:
|
||||
logger.warning(f" kcar user_metadata patch failed ({r.status_code}): {r.text[:120]}")
|
||||
except Exception as e:
|
||||
errors.append(f"kcar_user_type: {e}")
|
||||
logger.error(f" {e}")
|
||||
try:
|
||||
r = requests.patch(
|
||||
f"{url}/rest/v1/profiles",
|
||||
headers={**headers, "Prefer": "return=minimal"},
|
||||
params={"id": f"eq.{KCAR_ID}"},
|
||||
json={"school_id": None},
|
||||
)
|
||||
if r.status_code in (200, 204):
|
||||
logger.info(" kcar → profiles.school_id: null ✓")
|
||||
else:
|
||||
logger.warning(f" kcar profiles patch failed ({r.status_code}): {r.text[:120]}")
|
||||
except Exception as e:
|
||||
errors.append(f"kcar_profile: {e}")
|
||||
logger.error(f" {e}")
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────
|
||||
results["success"] = len(errors) == 0
|
||||
results["errors"] = errors
|
||||
|
||||
_print_credential_sheet(created_users, accounts)
|
||||
|
||||
logger.info("\n" + "=" * 60)
|
||||
if errors:
|
||||
logger.info(f"SEED COMPLETE with {len(errors)} error(s)")
|
||||
for e in errors:
|
||||
logger.info(f" ✗ {e}")
|
||||
else:
|
||||
logger.info("SEED COMPLETE — all steps succeeded")
|
||||
logger.info("=" * 60)
|
||||
return results
|
||||
|
||||
|
||||
def _print_credential_sheet(created_users: Dict[str, str], accounts: List[Dict]):
|
||||
PAD = 36
|
||||
include_passwords = os.getenv("PRINT_SEED_CREDENTIALS", "").lower() in {"1", "true", "yes", "on"}
|
||||
logger.info("\n" + "=" * 70)
|
||||
logger.info("CREDENTIAL SHEET" + ("" if include_passwords else " (passwords redacted; set PRINT_SEED_CREDENTIALS=true to print)"))
|
||||
logger.info("=" * 70)
|
||||
logger.info(f" {'ROLE':<16} {'EMAIL':<{PAD}} PASSWORD")
|
||||
logger.info(f" {'-'*14} {'-'*(PAD-2)} -----------")
|
||||
platform_password = get_seed_password("platform_admin") if include_passwords else "<redacted>"
|
||||
logger.info(f" {'[platform admin]':<16} {'[email protected]':<{PAD}} {platform_password}")
|
||||
logger.info("")
|
||||
|
||||
for school_id, domain, label in [
|
||||
(KEVLARAI_ID, KEVLARAI_DOMAIN, "KevlarAI"),
|
||||
(GREENFIELD_ID, GREENFIELD_DOMAIN, "Greenfield Academy"),
|
||||
]:
|
||||
logger.info(f" [{label}]")
|
||||
for spec in accounts:
|
||||
if spec["institute_id"] != school_id:
|
||||
continue
|
||||
uid = created_users.get(spec["email"], "—")
|
||||
status = f"[{uid[:8]}]" if uid != "—" else "[MISSING]"
|
||||
password = spec["password"] if include_passwords else "<redacted>"
|
||||
logger.info(f" {spec['role']:<16} {spec['email']:<{PAD}} {password} {status}")
|
||||
logger.info("")
|
||||
logger.info("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
print(json.dumps(seed(test="--test" in os.sys.argv), indent=2, default=str))
|
||||
@@ -0,0 +1,423 @@
|
||||
"""
|
||||
seed_file_cabinets.py — Create one file cabinet per class with sample document references.
|
||||
|
||||
Creates file_cabinets, files, and cabinet_memberships rows via Supabase REST API
|
||||
using the service role key. Also creates document_artefacts entries for sample files.
|
||||
|
||||
Each cabinet is owned by the class's primary teacher and shared with students
|
||||
in that class via cabinet_memberships.
|
||||
|
||||
Tables: file_cabinets, files, cabinet_memberships, document_artefacts
|
||||
|
||||
Run inside ccapi container:
|
||||
python3 -c "from run.initialization.seed_file_cabinets import seed; seed()"
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
import requests
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
SUPA_URL = os.environ["SUPABASE_URL"]
|
||||
SERVICE_KEY = os.environ["SERVICE_ROLE_KEY"]
|
||||
API_BASE = os.environ.get("API_BASE_URL", "http://localhost:8000")
|
||||
|
||||
# ─── Passwords (standardized from T4) ────────────────────────────────────────
|
||||
|
||||
PWD_ADMIN = "Admin@Cc2025!"
|
||||
PWD_TEACHER = "Teacher@Cc2025!"
|
||||
PWD_STUDENT = "Student@Cc2025!"
|
||||
|
||||
|
||||
# ─── Sample file data ────────────────────────────────────────────────────────
|
||||
# Each cabinet gets 2-3 sample files with realistic paths.
|
||||
|
||||
SAMPLE_FILES = {
|
||||
# Physics cabinets
|
||||
"lesson_plans": [
|
||||
{"name": "forces_motion_plan.pdf", "path": "cc.public.snapshots/lesson_plans/forces_motion.pdf", "mime_type": "application/pdf", "size": "245KB"},
|
||||
{"name": "electric_circuits_plan.pdf", "path": "cc.public.snapshots/lesson_plans/electric_circuits.pdf", "mime_type": "application/pdf", "size": "312KB"},
|
||||
],
|
||||
"worksheets": [
|
||||
{"name": "worksheet_fma.pdf", "path": "cc.public.snapshots/worksheets/fma_practice.pdf", "mime_type": "application/pdf", "size": "128KB"},
|
||||
{"name": "worksheet_resistance.pdf", "path": "cc.public.snapshots/worksheets/resistance_calc.pdf", "mime_type": "application/pdf", "size": "95KB"},
|
||||
],
|
||||
"presentations": [
|
||||
{"name": "intro_forces.pptx", "path": "cc.public.snapshots/presentations/forces_intro.pptx", "mime_type": "application/vnd.openxmlformats-officedocument.presentationml.presentation", "size": "2.1MB"},
|
||||
],
|
||||
# Maths cabinets
|
||||
"lesson_plans": [
|
||||
{"name": "quadratic_factorisation_plan.pdf", "path": "cc.public.snapshots/lesson_plans/quadratics.pdf", "mime_type": "application/pdf", "size": "278KB"},
|
||||
],
|
||||
"worksheets": [
|
||||
{"name": "worksheet_quadratics.pdf", "path": "cc.public.snapshots/worksheets/quadratic_practice.pdf", "mime_type": "application/pdf", "size": "156KB"},
|
||||
{"name": "worksheet_tree_diagrams.pdf", "path": "cc.public.snapshots/worksheets/tree_diagrams.pdf", "mime_type": "application/pdf", "size": "134KB"},
|
||||
],
|
||||
# CS cabinets
|
||||
"lesson_plans": [
|
||||
{"name": "intro_python_plan.pdf", "path": "cc.public.snapshots/lesson_plans/intro_python.pdf", "mime_type": "application/pdf", "size": "198KB"},
|
||||
],
|
||||
"code_samples": [
|
||||
{"name": "hello_world.py", "path": "cc.public.snapshots/code_samples/hello_world.py", "mime_type": "text/x-python", "size": "0.5KB"},
|
||||
{"name": "variables.py", "path": "cc.public.snapshots/code_samples/variables.py", "mime_type": "text/x-python", "size": "1.2KB"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sb_headers() -> Dict:
|
||||
return {
|
||||
"apikey": SERVICE_KEY,
|
||||
"Authorization": f"Bearer {SERVICE_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _sign_in(email: str, password: str) -> str:
|
||||
r = requests.post(
|
||||
f"{SUPA_URL}/auth/v1/token?grant_type=password",
|
||||
headers={"apikey": SERVICE_KEY, "Content-Type": "application/json"},
|
||||
json={"email": email, "password": password},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def _get_profile_id(email: str) -> Optional[str]:
|
||||
"""Look up a profile's UUID by email via Supabase service role."""
|
||||
r = requests.get(
|
||||
f"{SUPA_URL}/rest/v1/profiles",
|
||||
headers=_sb_headers(),
|
||||
params={"email": f"eq.{email}", "select": "id", "limit": "1"},
|
||||
)
|
||||
data = r.json() if r.ok else []
|
||||
return data[0]["id"] if data else None
|
||||
|
||||
|
||||
def _get_class_info(admin_token: str, class_code: str) -> Optional[Dict]:
|
||||
"""Get class info including teacher and students."""
|
||||
r = requests.get(
|
||||
f"{API_BASE}/database/timetable/classes",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
params={"class_code": class_code},
|
||||
)
|
||||
if not r.ok:
|
||||
return None
|
||||
data = r.json()
|
||||
if isinstance(data, list) and data:
|
||||
return data[0]
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
return None
|
||||
|
||||
|
||||
def _get_class_students(admin_token: str, class_id: str) -> List[str]:
|
||||
"""Get student profile IDs enrolled in a class."""
|
||||
r = requests.get(
|
||||
f"{API_BASE}/database/timetable/classes/{class_id}/students",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
if r.ok:
|
||||
data = r.json()
|
||||
if isinstance(data, list):
|
||||
return [s.get("student_id") or s.get("id") for s in data if s.get("student_id") or s.get("id")]
|
||||
return []
|
||||
|
||||
|
||||
# ─── Main seed ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def seed() -> Dict[str, Any]:
|
||||
print("=" * 60)
|
||||
print("File cabinets seed — both schools")
|
||||
print("=" * 60)
|
||||
results: Dict[str, Any] = {}
|
||||
errors: List[str] = []
|
||||
|
||||
# ── Sign in as both school admins ───────────────────────────────────────
|
||||
print("\n[1] Signing in as school admins...")
|
||||
admin_tokens = {}
|
||||
for school, email, pwd in [
|
||||
("KevlarAI", "[email protected]", PWD_ADMIN),
|
||||
("Greenfield", "[email protected]", PWD_ADMIN),
|
||||
]:
|
||||
try:
|
||||
token = _sign_in(email, pwd)
|
||||
admin_tokens[school] = token
|
||||
print(f" ✓ {school} admin signed in")
|
||||
except Exception as e:
|
||||
print(f" ✗ {school} admin login failed: {e}")
|
||||
errors.append(f"{school}_admin_login: {e}")
|
||||
|
||||
if not admin_tokens:
|
||||
return {"success": False, "error": "No admin tokens obtained"}
|
||||
|
||||
# ── Resolve class codes per school ──────────────────────────────────────
|
||||
print("\n[2] Resolving classes per school...")
|
||||
|
||||
# KevlarAI classes
|
||||
kevlarai_classes = [
|
||||
("10K/Ph1", "[email protected]"),
|
||||
("11K/Ph1", "[email protected]"),
|
||||
("10K/Ma1", "[email protected]"),
|
||||
("11K/Ma1", "[email protected]"),
|
||||
("10K/CS1", "[email protected]"),
|
||||
("11K/CS1", "[email protected]"),
|
||||
("9K/Ph1", "[email protected]"),
|
||||
("9K/Ma1", "[email protected]"),
|
||||
]
|
||||
|
||||
# Greenfield classes (subset — just a few for cabinet seeding)
|
||||
greenfield_classes = [
|
||||
("9P/Ph1", "[email protected]"),
|
||||
("10P/Ph2", "[email protected]"),
|
||||
("9M/Ma1", "[email protected]"),
|
||||
("10M/Ma1", "[email protected]"),
|
||||
("9En/1", "[email protected]"),
|
||||
("10Hs/1", "[email protected]"),
|
||||
]
|
||||
|
||||
# ── Seed KevlarAI cabinets ──────────────────────────────────────────────
|
||||
print("\n[3] Seeding KevlarAI file cabinets...")
|
||||
results["kevlarai"] = {"cabinets": 0, "files": 0, "memberships": 0}
|
||||
|
||||
for class_code, teacher_email in kevlarai_classes:
|
||||
try:
|
||||
# Get class info
|
||||
class_info = _get_class_info(admin_tokens["KevlarAI"], class_code)
|
||||
if not class_info:
|
||||
print(f" ✗ class not found: {class_code}")
|
||||
errors.append(f"class_not_found: {class_code}")
|
||||
continue
|
||||
|
||||
class_id = class_info.get("id") or class_info
|
||||
teacher_pid = _get_profile_id(teacher_email)
|
||||
if not teacher_pid:
|
||||
print(f" ✗ teacher profile not found: {teacher_email}")
|
||||
errors.append(f"teacher_profile_not_found: {teacher_email}")
|
||||
continue
|
||||
|
||||
# Get students in this class
|
||||
student_ids = _get_class_students(admin_tokens["KevlarAI"], str(class_id))
|
||||
|
||||
# Determine file category based on subject
|
||||
subject = (class_info.get("subject") or "").lower()
|
||||
if "physics" in subject:
|
||||
file_category = "lesson_plans"
|
||||
elif "math" in subject:
|
||||
file_category = "worksheets"
|
||||
elif "cs" in subject or "computer" in subject:
|
||||
file_category = "code_samples"
|
||||
else:
|
||||
file_category = "lesson_plans"
|
||||
|
||||
files_list = SAMPLE_FILES.get(file_category, SAMPLE_FILES["lesson_plans"])
|
||||
|
||||
# Create cabinet
|
||||
cabinet_id = str(uuid.uuid4())
|
||||
cabinet_name = f"{class_code} — {class_info.get('name', class_code)}"
|
||||
|
||||
r = requests.post(
|
||||
f"{SUPA_URL}/rest/v1/file_cabinets",
|
||||
headers={**_sb_headers(), "Prefer": "return=representation"},
|
||||
json={"id": cabinet_id, "user_id": teacher_pid, "name": cabinet_name},
|
||||
params={"on_conflict": "id"},
|
||||
)
|
||||
if r.status_code in (200, 201):
|
||||
print(f" ✓ Cabinet: {cabinet_name}")
|
||||
results["kevlarai"]["cabinets"] += 1
|
||||
else:
|
||||
print(f" ✗ Cabinet create failed ({class_code}): {r.text[:100]}")
|
||||
errors.append(f"cabinet_create: {class_code}")
|
||||
continue
|
||||
|
||||
# Create files in cabinet
|
||||
for fi in files_list:
|
||||
file_id = str(uuid.uuid4())
|
||||
r = requests.post(
|
||||
f"{SUPA_URL}/rest/v1/files",
|
||||
headers={**_sb_headers(), "Prefer": "return=representation"},
|
||||
json={
|
||||
"id": file_id,
|
||||
"cabinet_id": cabinet_id,
|
||||
"name": fi["name"],
|
||||
"path": fi["path"],
|
||||
"bucket": "file-cabinets",
|
||||
"mime_type": fi.get("mime_type"),
|
||||
"size": fi.get("size"),
|
||||
"metadata": {},
|
||||
},
|
||||
params={"on_conflict": "id"},
|
||||
)
|
||||
if r.status_code in (200, 201):
|
||||
results["kevlarai"]["files"] += 1
|
||||
|
||||
# Create document_artefact for this file
|
||||
artefact_id = str(uuid.uuid4())
|
||||
requests.post(
|
||||
f"{SUPA_URL}/rest/v1/document_artefacts",
|
||||
headers={**_sb_headers(), "Prefer": "return=representation"},
|
||||
json={
|
||||
"id": artefact_id,
|
||||
"file_id": file_id,
|
||||
"type": fi.get("mime_type", "application/octet-stream").split("/")[-1],
|
||||
"rel_path": fi["path"],
|
||||
"status": "processed",
|
||||
"extra": {"seeded": True, "source": "seed_file_cabinets"},
|
||||
},
|
||||
params={"on_conflict": "id"},
|
||||
)
|
||||
|
||||
time.sleep(0.05)
|
||||
|
||||
# Create cabinet memberships for students
|
||||
for sid in student_ids:
|
||||
r = requests.post(
|
||||
f"{SUPA_URL}/rest/v1/cabinet_memberships",
|
||||
headers={**_sb_headers(), "Prefer": "return=minimal"},
|
||||
json={
|
||||
"cabinet_id": cabinet_id,
|
||||
"profile_id": sid,
|
||||
"role": "viewer",
|
||||
},
|
||||
params={"on_conflict": "cabinet_id,profile_id"},
|
||||
)
|
||||
if r.status_code in (200, 201, 409):
|
||||
results["kevlarai"]["memberships"] += 1
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
err = f"cabinet seed {class_code}: {e}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
|
||||
# ── Seed Greenfield cabinets ────────────────────────────────────────────
|
||||
print("\n[4] Seeding Greenfield file cabinets...")
|
||||
results["greenfield"] = {"cabinets": 0, "files": 0, "memberships": 0}
|
||||
|
||||
for class_code, teacher_email in greenfield_classes:
|
||||
try:
|
||||
class_info = _get_class_info(admin_tokens["Greenfield"], class_code)
|
||||
if not class_info:
|
||||
print(f" ✗ class not found: {class_code}")
|
||||
errors.append(f"class_not_found: {class_code}")
|
||||
continue
|
||||
|
||||
class_id = class_info.get("id") or class_info
|
||||
teacher_pid = _get_profile_id(teacher_email)
|
||||
if not teacher_pid:
|
||||
print(f" ✗ teacher profile not found: {teacher_email}")
|
||||
errors.append(f"teacher_profile_not_found: {teacher_email}")
|
||||
continue
|
||||
|
||||
student_ids = _get_class_students(admin_tokens["Greenfield"], str(class_id))
|
||||
|
||||
subject = (class_info.get("subject") or "").lower()
|
||||
if "physics" in subject:
|
||||
file_category = "lesson_plans"
|
||||
elif "math" in subject:
|
||||
file_category = "worksheets"
|
||||
elif "english" in subject:
|
||||
file_category = "presentations"
|
||||
elif "history" in subject:
|
||||
file_category = "lesson_plans"
|
||||
else:
|
||||
file_category = "lesson_plans"
|
||||
|
||||
files_list = SAMPLE_FILES.get(file_category, SAMPLE_FILES["lesson_plans"])
|
||||
|
||||
cabinet_id = str(uuid.uuid4())
|
||||
cabinet_name = f"{class_code} — {class_info.get('name', class_code)}"
|
||||
|
||||
r = requests.post(
|
||||
f"{SUPA_URL}/rest/v1/file_cabinets",
|
||||
headers={**_sb_headers(), "Prefer": "return=representation"},
|
||||
json={"id": cabinet_id, "user_id": teacher_pid, "name": cabinet_name},
|
||||
params={"on_conflict": "id"},
|
||||
)
|
||||
if r.status_code in (200, 201):
|
||||
print(f" ✓ Cabinet: {cabinet_name}")
|
||||
results["greenfield"]["cabinets"] += 1
|
||||
else:
|
||||
print(f" ✗ Cabinet create failed ({class_code}): {r.text[:100]}")
|
||||
errors.append(f"cabinet_create: {class_code}")
|
||||
continue
|
||||
|
||||
for fi in files_list:
|
||||
file_id = str(uuid.uuid4())
|
||||
r = requests.post(
|
||||
f"{SUPA_URL}/rest/v1/files",
|
||||
headers={**_sb_headers(), "Prefer": "return=representation"},
|
||||
json={
|
||||
"id": file_id,
|
||||
"cabinet_id": cabinet_id,
|
||||
"name": fi["name"],
|
||||
"path": fi["path"],
|
||||
"bucket": "file-cabinets",
|
||||
"mime_type": fi.get("mime_type"),
|
||||
"size": fi.get("size"),
|
||||
"metadata": {},
|
||||
},
|
||||
params={"on_conflict": "id"},
|
||||
)
|
||||
if r.status_code in (200, 201):
|
||||
results["greenfield"]["files"] += 1
|
||||
|
||||
artefact_id = str(uuid.uuid4())
|
||||
requests.post(
|
||||
f"{SUPA_URL}/rest/v1/document_artefacts",
|
||||
headers={**_sb_headers(), "Prefer": "return=representation"},
|
||||
json={
|
||||
"id": artefact_id,
|
||||
"file_id": file_id,
|
||||
"type": fi.get("mime_type", "application/octet-stream").split("/")[-1],
|
||||
"rel_path": fi["path"],
|
||||
"status": "processed",
|
||||
"extra": {"seeded": True, "source": "seed_file_cabinets"},
|
||||
},
|
||||
params={"on_conflict": "id"},
|
||||
)
|
||||
|
||||
time.sleep(0.05)
|
||||
|
||||
for sid in student_ids:
|
||||
r = requests.post(
|
||||
f"{SUPA_URL}/rest/v1/cabinet_memberships",
|
||||
headers={**_sb_headers(), "Prefer": "return=minimal"},
|
||||
json={
|
||||
"cabinet_id": cabinet_id,
|
||||
"profile_id": sid,
|
||||
"role": "viewer",
|
||||
},
|
||||
params={"on_conflict": "cabinet_id,profile_id"},
|
||||
)
|
||||
if r.status_code in (200, 201, 409):
|
||||
results["greenfield"]["memberships"] += 1
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
err = f"cabinet seed {class_code}: {e}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
|
||||
# ── Summary ─────────────────────────────────────────────────────────────
|
||||
print("\n" + "=" * 60)
|
||||
results["success"] = len(errors) == 0
|
||||
results["errors"] = errors
|
||||
total_cabinets = results["kevlarai"]["cabinets"] + results["greenfield"]["cabinets"]
|
||||
total_files = results["kevlarai"]["files"] + results["greenfield"]["files"]
|
||||
total_memberships = results["kevlarai"]["memberships"] + results["greenfield"]["memberships"]
|
||||
print(f"COMPLETE — {total_cabinets} cabinets, {total_files} files, {total_memberships} memberships")
|
||||
if errors:
|
||||
print(f"Errors ({len(errors)}):")
|
||||
for e in errors:
|
||||
print(f" ✗ {e}")
|
||||
print("=" * 60)
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
print(json.dumps(seed(), indent=2, default=str))
|
||||
@@ -0,0 +1,493 @@
|
||||
"""
|
||||
seed_greenfield_timetable.py — Full timetable + class + student seed for Greenfield Academy.
|
||||
|
||||
Flow:
|
||||
1. POST /timetable/setup — academic year, 3 terms, periods → Supabase
|
||||
2. POST /timetable/materialize-periods — academic_periods rows (days × template)
|
||||
3. Create classes — 17 classes with correct metadata
|
||||
4. Add teachers to classes — primary teacher per class
|
||||
5. POST /timetable/init + slots — TeacherTimetable + slot assignments
|
||||
6. Patch slot class_ids — write class_id FK onto teacher_timetable_slots
|
||||
7. Enroll students in classes — student1→Yr9, student2→Yr10, student3→Yr11
|
||||
8. POST /timetable/materialize — taught_lessons with class_id populated
|
||||
9. POST /timetable/sync-lessons — Neo4j TaughtLesson nodes (B.10)
|
||||
|
||||
Run inside ccapi container:
|
||||
python3 -c "from run.initialization.seed_greenfield_timetable import seed; seed()"
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import requests
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
from run.initialization.seed_environment import get_seed_password
|
||||
|
||||
GREENFIELD_ADMIN_EMAIL = "[email protected]"
|
||||
|
||||
|
||||
def _runtime_context() -> Dict[str, str]:
|
||||
return {
|
||||
"supa_url": os.environ["SUPABASE_URL"],
|
||||
"service_key": os.environ["SERVICE_ROLE_KEY"],
|
||||
"api_base": os.environ.get("API_BASE_URL", "http://localhost:8000"),
|
||||
}
|
||||
|
||||
# ─── Period templates ──────────────────────────────────────────────────────────
|
||||
|
||||
PERIODS = [
|
||||
{"code": "REG", "name": "Registration", "start_time": "08:45", "end_time": "09:00", "period_type": "registration"},
|
||||
{"code": "P1", "name": "Period 1", "start_time": "09:00", "end_time": "10:00", "period_type": "lesson"},
|
||||
{"code": "P2", "name": "Period 2", "start_time": "10:00", "end_time": "11:00", "period_type": "lesson"},
|
||||
{"code": "BRK", "name": "Break", "start_time": "11:00", "end_time": "11:20", "period_type": "break"},
|
||||
{"code": "P3", "name": "Period 3", "start_time": "11:20", "end_time": "12:20", "period_type": "lesson"},
|
||||
{"code": "P4", "name": "Period 4", "start_time": "12:20", "end_time": "13:20", "period_type": "lesson"},
|
||||
{"code": "LUN", "name": "Lunch", "start_time": "13:20", "end_time": "14:00", "period_type": "break"},
|
||||
{"code": "P5", "name": "Period 5", "start_time": "14:00", "end_time": "15:00", "period_type": "lesson"},
|
||||
]
|
||||
|
||||
PERIOD_TIMES = {p["code"]: (p["start_time"], p["end_time"]) for p in PERIODS}
|
||||
|
||||
# ─── Academic year ─────────────────────────────────────────────────────────────
|
||||
|
||||
TERMS = [
|
||||
{"name": "Autumn Term", "term_number": 1, "start_date": "2025-09-03", "end_date": "2025-12-19"},
|
||||
{"name": "Spring Term", "term_number": 2, "start_date": "2026-01-05", "end_date": "2026-04-01"},
|
||||
{"name": "Summer Term", "term_number": 3, "start_date": "2026-04-20", "end_date": "2026-07-17"},
|
||||
]
|
||||
|
||||
# ─── Class definitions ─────────────────────────────────────────────────────────
|
||||
# Covers every unique subject_class code in TEACHER_SLOTS.
|
||||
# key_stage: KS3 = years 7-9, KS4 = years 10-11, KS5 = years 12-13
|
||||
|
||||
CLASSES = [
|
||||
# Physics
|
||||
{"class_code": "9P/Ph1", "name": "Year 9 Physics Group 1", "subject": "Physics", "year_group": "9", "key_stage": "3", "teacher": "[email protected]"},
|
||||
{"class_code": "10P/Ph2", "name": "Year 10 Physics Group 2", "subject": "Physics", "year_group": "10", "key_stage": "4", "teacher": "[email protected]"},
|
||||
{"class_code": "11P/Ph1", "name": "Year 11 Physics Group 1", "subject": "Physics", "year_group": "11", "key_stage": "4", "teacher": "[email protected]"},
|
||||
{"class_code": "12P/Ph1", "name": "Year 12 Physics Group 1", "subject": "Physics", "year_group": "12", "key_stage": "5", "teacher": "[email protected]"},
|
||||
# Maths
|
||||
{"class_code": "9M/Ma1", "name": "Year 9 Maths Group 1", "subject": "Mathematics", "year_group": "9", "key_stage": "3", "teacher": "[email protected]"},
|
||||
{"class_code": "10M/Ma1", "name": "Year 10 Maths Group 1", "subject": "Mathematics", "year_group": "10", "key_stage": "4", "teacher": "[email protected]"},
|
||||
{"class_code": "11M/Ma2", "name": "Year 11 Maths Group 2", "subject": "Mathematics", "year_group": "11", "key_stage": "4", "teacher": "[email protected]"},
|
||||
# English
|
||||
{"class_code": "7En/1", "name": "Year 7 English Group 1", "subject": "English", "year_group": "7", "key_stage": "3", "teacher": "[email protected]"},
|
||||
{"class_code": "8En/1", "name": "Year 8 English Group 1", "subject": "English", "year_group": "8", "key_stage": "3", "teacher": "[email protected]"},
|
||||
{"class_code": "9En/1", "name": "Year 9 English Group 1", "subject": "English", "year_group": "9", "key_stage": "3", "teacher": "[email protected]"},
|
||||
# History
|
||||
{"class_code": "8Hs/1", "name": "Year 8 History Group 1", "subject": "History", "year_group": "8", "key_stage": "3", "teacher": "[email protected]"},
|
||||
{"class_code": "9Hs/1", "name": "Year 9 History Group 1", "subject": "History", "year_group": "9", "key_stage": "3", "teacher": "[email protected]"},
|
||||
{"class_code": "10Hs/1", "name": "Year 10 History Group 1", "subject": "History", "year_group": "10", "key_stage": "4", "teacher": "[email protected]"},
|
||||
# Science
|
||||
{"class_code": "7Sc/1", "name": "Year 7 Science Group 1", "subject": "Science", "year_group": "7", "key_stage": "3", "teacher": "[email protected]"},
|
||||
{"class_code": "8Sc/1", "name": "Year 8 Science Group 1", "subject": "Science", "year_group": "8", "key_stage": "3", "teacher": "[email protected]"},
|
||||
{"class_code": "9Sc/1", "name": "Year 9 Science Group 1", "subject": "Science", "year_group": "9", "key_stage": "3", "teacher": "[email protected]"},
|
||||
{"class_code": "10Sc/1", "name": "Year 10 Science Group 1", "subject": "Science", "year_group": "10", "key_stage": "4", "teacher": "[email protected]"},
|
||||
]
|
||||
|
||||
# ─── Teacher slot assignments ──────────────────────────────────────────────────
|
||||
|
||||
TEACHER_SLOTS = {
|
||||
"[email protected]": [
|
||||
("Monday", "P1", "11P/Ph1"),
|
||||
("Monday", "P3", "12P/Ph1"),
|
||||
("Tuesday", "P2", "10P/Ph2"),
|
||||
("Tuesday", "P4", "9P/Ph1"),
|
||||
("Wednesday", "P1", "11P/Ph1"),
|
||||
("Wednesday", "P5", "12P/Ph1"),
|
||||
("Thursday", "P3", "10P/Ph2"),
|
||||
("Thursday", "P5", "9P/Ph1"),
|
||||
("Friday", "P2", "11P/Ph1"),
|
||||
("Friday", "P4", "12P/Ph1"),
|
||||
],
|
||||
"[email protected]": [
|
||||
("Monday", "P2", "10M/Ma1"),
|
||||
("Monday", "P4", "11M/Ma2"),
|
||||
("Tuesday", "P1", "9M/Ma1"),
|
||||
("Tuesday", "P3", "10M/Ma1"),
|
||||
("Wednesday", "P2", "11M/Ma2"),
|
||||
("Wednesday", "P4", "9M/Ma1"),
|
||||
("Thursday", "P1", "10M/Ma1"),
|
||||
("Thursday", "P4", "11M/Ma2"),
|
||||
("Friday", "P1", "9M/Ma1"),
|
||||
("Friday", "P3", "10M/Ma1"),
|
||||
],
|
||||
"[email protected]": [
|
||||
("Monday", "P1", "7En/1"),
|
||||
("Monday", "P5", "8En/1"),
|
||||
("Tuesday", "P2", "9En/1"),
|
||||
("Wednesday", "P3", "7En/1"),
|
||||
("Thursday", "P2", "8En/1"),
|
||||
("Friday", "P5", "9En/1"),
|
||||
],
|
||||
"[email protected]": [
|
||||
("Monday", "P3", "8Hs/1"),
|
||||
("Tuesday", "P5", "9Hs/1"),
|
||||
("Wednesday", "P1", "10Hs/1"),
|
||||
("Thursday", "P2", "8Hs/1"),
|
||||
("Friday", "P3", "9Hs/1"),
|
||||
],
|
||||
"[email protected]": [
|
||||
("Monday", "P4", "7Sc/1"),
|
||||
("Tuesday", "P3", "8Sc/1"),
|
||||
("Wednesday", "P5", "9Sc/1"),
|
||||
("Thursday", "P4", "10Sc/1"),
|
||||
("Friday", "P2", "7Sc/1"),
|
||||
],
|
||||
}
|
||||
|
||||
# ─── Student enrollments ───────────────────────────────────────────────────────
|
||||
# One student per year-group band — enrolled in all subjects for that year.
|
||||
|
||||
STUDENT_ENROLLMENTS = {
|
||||
"[email protected]": ["9P/Ph1", "9M/Ma1", "9En/1", "9Hs/1", "9Sc/1"],
|
||||
"[email protected]": ["10P/Ph2", "10M/Ma1", "10Hs/1", "10Sc/1"],
|
||||
"[email protected]": ["11P/Ph1", "11M/Ma2"],
|
||||
}
|
||||
|
||||
|
||||
# ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sb_headers() -> Dict:
|
||||
service_key = _runtime_context()["service_key"]
|
||||
return {
|
||||
"apikey": service_key,
|
||||
"Authorization": f"Bearer {service_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Prefer": "return=representation",
|
||||
}
|
||||
|
||||
|
||||
def _sign_in(email: str, password: str) -> str:
|
||||
ctx = _runtime_context()
|
||||
r = requests.post(
|
||||
f"{ctx['supa_url']}/auth/v1/token?grant_type=password",
|
||||
headers={"apikey": ctx["service_key"], "Content-Type": "application/json"},
|
||||
json={"email": email, "password": password},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def _api(token: str, method: str, path: str, body: Dict = None) -> Dict:
|
||||
api_base = _runtime_context()["api_base"]
|
||||
h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
r = getattr(requests, method)(f"{api_base}{path}", headers=h, json=body)
|
||||
try:
|
||||
return r.json()
|
||||
except Exception:
|
||||
return {"_raw": r.text, "_status": r.status_code}
|
||||
|
||||
|
||||
def _get_profile_id(email: str) -> Optional[str]:
|
||||
"""Look up a profile's UUID by email via Supabase service role."""
|
||||
supa_url = _runtime_context()["supa_url"]
|
||||
r = requests.get(
|
||||
f"{supa_url}/rest/v1/profiles",
|
||||
headers=_sb_headers(),
|
||||
params={"email": f"eq.{email}", "select": "id", "limit": "1"},
|
||||
)
|
||||
data = r.json() if r.ok else []
|
||||
return data[0]["id"] if data else None
|
||||
|
||||
|
||||
def _get_teacher_timetable_id(profile_id: str) -> Optional[str]:
|
||||
"""Return the Supabase teacher_timetables.id for a given profile."""
|
||||
supa_url = _runtime_context()["supa_url"]
|
||||
r = requests.get(
|
||||
f"{supa_url}/rest/v1/teacher_timetables",
|
||||
headers=_sb_headers(),
|
||||
params={"profile_id": f"eq.{profile_id}", "select": "id", "limit": "1"},
|
||||
)
|
||||
data = r.json() if r.ok else []
|
||||
return data[0]["id"] if data else None
|
||||
|
||||
|
||||
def _patch_slot_class_ids(teacher_tt_sb_id: str, class_code_to_id: Dict[str, str]) -> int:
|
||||
"""Update class_id FK on teacher_timetable_slots rows via Supabase service role."""
|
||||
supa_url = _runtime_context()["supa_url"]
|
||||
patched = 0
|
||||
for code, class_uuid in class_code_to_id.items():
|
||||
r = requests.patch(
|
||||
f"{supa_url}/rest/v1/teacher_timetable_slots",
|
||||
headers=_sb_headers(),
|
||||
params={
|
||||
"teacher_timetable_id": f"eq.{teacher_tt_sb_id}",
|
||||
"subject_class": f"eq.{code}",
|
||||
},
|
||||
json={"class_id": class_uuid},
|
||||
)
|
||||
if r.ok:
|
||||
patched += 1
|
||||
return patched
|
||||
|
||||
|
||||
# ─── Main seed ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def seed() -> Dict[str, Any]:
|
||||
print("=" * 60)
|
||||
print("Greenfield Academy — full timetable + class + student seed")
|
||||
print("=" * 60)
|
||||
results: Dict[str, Any] = {}
|
||||
errors: List[str] = []
|
||||
|
||||
# ── [1] Sign in as Greenfield admin ───────────────────────────────────────
|
||||
print("\n[1] Signing in as [email protected]...")
|
||||
try:
|
||||
admin_token = _sign_in(GREENFIELD_ADMIN_EMAIL, get_seed_password("school_admin"))
|
||||
print(" ✓ signed in")
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# ── [2] POST /timetable/setup ─────────────────────────────────────────────
|
||||
print("\n[2] Setting up school timetable (academic year + terms + periods)...")
|
||||
r = _api(admin_token, "post", "/timetable/setup", {
|
||||
"year_start": "2025-09-03",
|
||||
"year_end": "2026-07-17",
|
||||
"terms": TERMS,
|
||||
"periods": PERIODS,
|
||||
})
|
||||
if r.get("status") == "ok" or r.get("school_timetable_id") or r.get("timetable_id"):
|
||||
print(f" ✓ timetable: {r.get('school_timetable_id') or r.get('timetable_id')}")
|
||||
results["setup"] = "ok"
|
||||
else:
|
||||
err = f"timetable/setup: {r}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["setup"] = "error"
|
||||
|
||||
# ── [3] POST /timetable/materialize-periods ───────────────────────────────
|
||||
print("\n[3] Materializing academic_periods (days × periods_template)...")
|
||||
r = _api(admin_token, "post", "/timetable/materialize-periods", None)
|
||||
if r.get("status") == "ok":
|
||||
print(f" ✓ {r.get('created')} periods created across {r.get('academic_days')} academic days")
|
||||
results["materialize_periods"] = "ok"
|
||||
else:
|
||||
err = f"materialize-periods: {r}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["materialize_periods"] = "error"
|
||||
|
||||
# ── [5] Build profile-ID lookup for all teachers + students ───────────────
|
||||
print("\n[3] Resolving profile IDs for teachers and students...")
|
||||
all_emails = (
|
||||
list(TEACHER_SLOTS.keys())
|
||||
+ list(STUDENT_ENROLLMENTS.keys())
|
||||
)
|
||||
profile_ids: Dict[str, str] = {}
|
||||
for email in all_emails:
|
||||
pid = _get_profile_id(email)
|
||||
if pid:
|
||||
profile_ids[email] = pid
|
||||
print(f" ✓ {email} → {pid[:8]}…")
|
||||
else:
|
||||
print(f" ✗ profile not found for {email}")
|
||||
errors.append(f"profile_not_found: {email}")
|
||||
|
||||
# ── [6] Create classes ────────────────────────────────────────────────────
|
||||
print(f"\n[4] Creating {len(CLASSES)} classes...")
|
||||
results["classes"] = {}
|
||||
class_code_to_id: Dict[str, str] = {}
|
||||
|
||||
for cls in CLASSES:
|
||||
r = _api(admin_token, "post", "/database/timetable/classes", {
|
||||
"name": cls["name"],
|
||||
"class_code": cls["class_code"],
|
||||
"subject": cls["subject"],
|
||||
"year_group": cls["year_group"],
|
||||
"key_stage": cls["key_stage"],
|
||||
"academic_year": "2025-2026",
|
||||
})
|
||||
class_id = r.get("id") or (r.get("class", {}) or {}).get("id")
|
||||
if class_id:
|
||||
class_code_to_id[cls["class_code"]] = class_id
|
||||
results["classes"][cls["class_code"]] = "ok"
|
||||
print(f" ✓ {cls['class_code']} → {class_id[:8]}…")
|
||||
else:
|
||||
err = f"create class {cls['class_code']}: {r}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["classes"][cls["class_code"]] = "error"
|
||||
time.sleep(0.1)
|
||||
|
||||
# ── [7] Add teachers to their classes ────────────────────────────────────
|
||||
print("\n[5] Adding teachers to classes...")
|
||||
results["class_teachers"] = {}
|
||||
for cls in CLASSES:
|
||||
class_id = class_code_to_id.get(cls["class_code"])
|
||||
teacher_pid = profile_ids.get(cls["teacher"])
|
||||
if not class_id or not teacher_pid:
|
||||
results["class_teachers"][cls["class_code"]] = "skip"
|
||||
continue
|
||||
r = _api(admin_token, "post", f"/database/timetable/classes/{class_id}/teachers", {
|
||||
"teacher_id": teacher_pid,
|
||||
"is_primary": True,
|
||||
})
|
||||
if r.get("status") == "ok" or r.get("id"):
|
||||
print(f" ✓ {cls['teacher'].split('@')[0]} → {cls['class_code']}")
|
||||
results["class_teachers"][cls["class_code"]] = "ok"
|
||||
else:
|
||||
err = f"add teacher {cls['class_code']}: {r}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["class_teachers"][cls["class_code"]] = "error"
|
||||
time.sleep(0.1)
|
||||
|
||||
# ── [8] Teacher timetable init + slots ────────────────────────────────────
|
||||
print("\n[6] Initialising TeacherTimetable and setting slots for each teacher...")
|
||||
results["init"] = {}
|
||||
results["slots"] = {}
|
||||
teacher_tt_sb_ids: Dict[str, str] = {} # email → teacher_timetables.id
|
||||
|
||||
for teacher_email, slot_tuples in TEACHER_SLOTS.items():
|
||||
try:
|
||||
teacher_token = _sign_in(teacher_email, get_seed_password("teacher"))
|
||||
except Exception as e:
|
||||
err = f"login {teacher_email}: {e}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["init"][teacher_email] = "error"
|
||||
results["slots"][teacher_email] = "error"
|
||||
continue
|
||||
|
||||
# 6a: init TeacherTimetable
|
||||
r = _api(teacher_token, "post", "/timetable/init", None)
|
||||
if r.get("status") == "ok":
|
||||
print(f" ✓ init {teacher_email}")
|
||||
results["init"][teacher_email] = "ok"
|
||||
else:
|
||||
print(f" ~ init {teacher_email}: {r.get('message', r)} (may already exist)")
|
||||
results["init"][teacher_email] = "warn"
|
||||
|
||||
# 6b: get timetable_id (Neo4j uuid_string for slot FK)
|
||||
status_r = _api(teacher_token, "get", "/timetable/status", None)
|
||||
timetable_id = status_r.get("timetable_id")
|
||||
if not timetable_id:
|
||||
err = f"no timetable_id for {teacher_email}: {status_r}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["slots"][teacher_email] = "error"
|
||||
continue
|
||||
|
||||
# 6c: save slots (subject_class text; class_id patched separately)
|
||||
slot_list = [
|
||||
{
|
||||
"day_of_week": day,
|
||||
"period_code": code,
|
||||
"subject_class": cls,
|
||||
"start_time": PERIOD_TIMES[code][0],
|
||||
"end_time": PERIOD_TIMES[code][1],
|
||||
}
|
||||
for day, code, cls in slot_tuples
|
||||
]
|
||||
r = _api(teacher_token, "post", "/timetable/slots", {
|
||||
"timetable_id": timetable_id,
|
||||
"slots": slot_list,
|
||||
})
|
||||
if r.get("status") == "ok" or r.get("created") is not None:
|
||||
count = r.get("created") or len(slot_list)
|
||||
print(f" ✓ {teacher_email}: {count} slots")
|
||||
results["slots"][teacher_email] = "ok"
|
||||
else:
|
||||
err = f"slots {teacher_email}: {r}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["slots"][teacher_email] = "error"
|
||||
|
||||
# record Supabase teacher_timetable FK for patching
|
||||
teacher_pid = profile_ids.get(teacher_email)
|
||||
if teacher_pid:
|
||||
tt_sb_id = _get_teacher_timetable_id(teacher_pid)
|
||||
if tt_sb_id:
|
||||
teacher_tt_sb_ids[teacher_email] = tt_sb_id
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
# ── [9] Patch teacher_timetable_slots.class_id ────────────────────────────
|
||||
print("\n[7] Patching class_id onto teacher_timetable_slots...")
|
||||
results["slot_patch"] = {}
|
||||
for teacher_email, slot_tuples in TEACHER_SLOTS.items():
|
||||
tt_sb_id = teacher_tt_sb_ids.get(teacher_email)
|
||||
if not tt_sb_id:
|
||||
results["slot_patch"][teacher_email] = "skip"
|
||||
continue
|
||||
teacher_codes = {cls for _, _, cls in slot_tuples}
|
||||
relevant_map = {code: uid for code, uid in class_code_to_id.items() if code in teacher_codes}
|
||||
n = _patch_slot_class_ids(tt_sb_id, relevant_map)
|
||||
print(f" ✓ {teacher_email}: {n} slots patched")
|
||||
results["slot_patch"][teacher_email] = n
|
||||
|
||||
# ── [10] Enroll students in classes ───────────────────────────────────────
|
||||
print("\n[8] Enrolling students in classes...")
|
||||
results["enrollments"] = {}
|
||||
for student_email, class_codes in STUDENT_ENROLLMENTS.items():
|
||||
student_pid = profile_ids.get(student_email)
|
||||
results["enrollments"][student_email] = {}
|
||||
if not student_pid:
|
||||
results["enrollments"][student_email] = "no_profile"
|
||||
continue
|
||||
for code in class_codes:
|
||||
class_id = class_code_to_id.get(code)
|
||||
if not class_id:
|
||||
results["enrollments"][student_email][code] = "no_class"
|
||||
continue
|
||||
r = _api(admin_token, "post", f"/database/timetable/classes/{class_id}/students", {
|
||||
"student_id": student_pid,
|
||||
})
|
||||
if r.get("status") == "ok" or r.get("id"):
|
||||
print(f" ✓ {student_email.split('@')[0]} → {code}")
|
||||
results["enrollments"][student_email][code] = "ok"
|
||||
else:
|
||||
err = f"enroll {student_email} → {code}: {r}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["enrollments"][student_email][code] = "error"
|
||||
time.sleep(0.1)
|
||||
|
||||
# ── [11] Materialize taught lessons ────────────────────────────────────────
|
||||
print("\n[9] Materializing taught lessons for each teacher...")
|
||||
results["materialize"] = {}
|
||||
for teacher_email in TEACHER_SLOTS:
|
||||
try:
|
||||
teacher_token = _sign_in(teacher_email, get_seed_password("teacher"))
|
||||
except Exception as e:
|
||||
err = f"login {teacher_email}: {e}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
continue
|
||||
r = _api(teacher_token, "post", "/timetable/materialize", None)
|
||||
if r.get("status") == "ok":
|
||||
print(f" ✓ {teacher_email}: {r.get('lessons_upserted', '?')} lessons, "
|
||||
f"{r.get('whiteboard_rooms_created', '?')} rooms")
|
||||
results["materialize"][teacher_email] = "ok"
|
||||
else:
|
||||
err = f"materialize {teacher_email}: {r}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["materialize"][teacher_email] = "error"
|
||||
time.sleep(0.3)
|
||||
|
||||
# ── [12] Neo4j sync (B.10) ────────────────────────────────────────────────
|
||||
print("\n[10] Syncing Neo4j TaughtLesson nodes (B.10)...")
|
||||
r = _api(admin_token, "post", "/timetable/sync-lessons", None)
|
||||
if r.get("status") == "ok":
|
||||
print(f" ✓ Neo4j sync: {r.get('taught_lessons')} lessons, "
|
||||
f"{r.get('teacher_timetables')} timetables, {r.get('slots')} slots")
|
||||
results["neo4j_sync"] = "ok"
|
||||
else:
|
||||
err = f"sync-lessons: {r}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["neo4j_sync"] = "error"
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────
|
||||
print("\n" + "=" * 60)
|
||||
results["success"] = len(errors) == 0
|
||||
results["errors"] = errors
|
||||
if errors:
|
||||
print(f"COMPLETE with {len(errors)} error(s):")
|
||||
for e in errors:
|
||||
print(f" ✗ {e}")
|
||||
else:
|
||||
print("COMPLETE — all steps succeeded")
|
||||
print("=" * 60)
|
||||
return results
|
||||
@@ -0,0 +1,456 @@
|
||||
"""
|
||||
seed_kevlarai_timetable.py — Full timetable + class + student seed for KevlarAI school.
|
||||
|
||||
Mirrors Greenfield's structure so both schools are testable.
|
||||
KevlarAI gets 8 classes across 3 subjects (Physics, Maths, Computer Science),
|
||||
2 teachers, and 2 students.
|
||||
|
||||
Flow:
|
||||
1. POST /timetable/setup — academic year, 3 terms, periods → Supabase
|
||||
2. POST /timetable/materialize-periods — academic_periods rows (days x template)
|
||||
3. Create classes — 8 classes with correct metadata
|
||||
4. Add teachers to classes — primary teacher per class
|
||||
5. POST /timetable/init + slots — TeacherTimetable + slot assignments
|
||||
6. Patch slot class_ids — write class_id FK onto teacher_timetable_slots
|
||||
7. Enroll students in classes — student1→Yr10, student2→Yr11
|
||||
8. POST /timetable/materialize — taught_lessons with class_id populated
|
||||
9. POST /timetable/sync-lessons — Neo4j TaughtLesson nodes (B.10)
|
||||
|
||||
Run inside ccapi container:
|
||||
python3 -c "from run.initialization.seed_kevlarai_timetable import seed; seed()"
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import requests
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
SUPA_URL = os.environ["SUPABASE_URL"]
|
||||
SERVICE_KEY = os.environ["SERVICE_ROLE_KEY"]
|
||||
API_BASE = os.environ.get("API_BASE_URL", "http://localhost:8000")
|
||||
|
||||
KEVLARAI_ADMIN_EMAIL = "[email protected]"
|
||||
KEVLARAI_ADMIN_PWD = "Admin@Cc2025!"
|
||||
PWD_TEACHER = "Teacher@Cc2025!"
|
||||
PWD_STUDENT = "Student@Cc2025!"
|
||||
|
||||
# ─── Period templates (same as Greenfield) ─────────────────────────────────────
|
||||
|
||||
PERIODS = [
|
||||
{"code": "REG", "name": "Registration", "start_time": "08:45", "end_time": "09:00", "period_type": "registration"},
|
||||
{"code": "P1", "name": "Period 1", "start_time": "09:00", "end_time": "10:00", "period_type": "lesson"},
|
||||
{"code": "P2", "name": "Period 2", "start_time": "10:00", "end_time": "11:00", "period_type": "lesson"},
|
||||
{"code": "BRK", "name": "Break", "start_time": "11:00", "end_time": "11:20", "period_type": "break"},
|
||||
{"code": "P3", "name": "Period 3", "start_time": "11:20", "end_time": "12:20", "period_type": "lesson"},
|
||||
{"code": "P4", "name": "Period 4", "start_time": "12:20", "end_time": "13:20", "period_type": "lesson"},
|
||||
{"code": "LUN", "name": "Lunch", "start_time": "13:20", "end_time": "14:00", "period_type": "break"},
|
||||
{"code": "P5", "name": "Period 5", "start_time": "14:00", "end_time": "15:00", "period_type": "lesson"},
|
||||
]
|
||||
|
||||
PERIOD_TIMES = {p["code"]: (p["start_time"], p["end_time"]) for p in PERIODS}
|
||||
|
||||
# ─── Academic year ─────────────────────────────────────────────────────────────
|
||||
|
||||
TERMS = [
|
||||
{"name": "Autumn Term", "term_number": 1, "start_date": "2025-09-03", "end_date": "2025-12-19"},
|
||||
{"name": "Spring Term", "term_number": 2, "start_date": "2026-01-05", "end_date": "2026-04-01"},
|
||||
{"name": "Summer Term", "term_number": 3, "start_date": "2026-04-20", "end_date": "2026-07-17"},
|
||||
]
|
||||
|
||||
# ─── Class definitions ─────────────────────────────────────────────────────────
|
||||
# KevlarAI: 8 classes across Physics, Maths, Computer Science
|
||||
|
||||
CLASSES = [
|
||||
# Physics
|
||||
{"class_code": "10K/Ph1", "name": "Year 10 Physics Group 1", "subject": "Physics", "year_group": "10", "key_stage": "4", "teacher": "[email protected]"},
|
||||
{"class_code": "11K/Ph1", "name": "Year 11 Physics Group 1", "subject": "Physics", "year_group": "11", "key_stage": "4", "teacher": "[email protected]"},
|
||||
# Maths
|
||||
{"class_code": "10K/Ma1", "name": "Year 10 Maths Group 1", "subject": "Mathematics", "year_group": "10", "key_stage": "4", "teacher": "[email protected]"},
|
||||
{"class_code": "11K/Ma1", "name": "Year 11 Maths Group 1", "subject": "Mathematics", "year_group": "11", "key_stage": "4", "teacher": "[email protected]"},
|
||||
# Computer Science
|
||||
{"class_code": "10K/CS1", "name": "Year 10 CS Group 1", "subject": "Computer Science", "year_group": "10", "key_stage": "4", "teacher": "[email protected]"},
|
||||
{"class_code": "11K/CS1", "name": "Year 11 CS Group 1", "subject": "Computer Science", "year_group": "11", "key_stage": "4", "teacher": "[email protected]"},
|
||||
# Additional KS3 classes for breadth
|
||||
{"class_code": "9K/Ph1", "name": "Year 9 Physics Group 1", "subject": "Physics", "year_group": "9", "key_stage": "3", "teacher": "[email protected]"},
|
||||
{"class_code": "9K/Ma1", "name": "Year 9 Maths Group 1", "subject": "Mathematics", "year_group": "9", "key_stage": "3", "teacher": "[email protected]"},
|
||||
]
|
||||
|
||||
# ─── Teacher slot assignments ──────────────────────────────────────────────────
|
||||
|
||||
TEACHER_SLOTS = {
|
||||
"[email protected]": [
|
||||
("Monday", "P1", "10K/Ph1"),
|
||||
("Monday", "P3", "11K/Ph1"),
|
||||
("Tuesday", "P2", "10K/CS1"),
|
||||
("Tuesday", "P4", "9K/Ph1"),
|
||||
("Wednesday", "P1", "11K/Ph1"),
|
||||
("Wednesday", "P5", "10K/Ph1"),
|
||||
("Thursday", "P3", "10K/CS1"),
|
||||
("Thursday", "P5", "9K/Ph1"),
|
||||
],
|
||||
"[email protected]": [
|
||||
("Monday", "P2", "10K/Ma1"),
|
||||
("Monday", "P4", "11K/Ma1"),
|
||||
("Tuesday", "P1", "9K/Ma1"),
|
||||
("Tuesday", "P3", "10K/Ma1"),
|
||||
("Wednesday", "P2", "11K/Ma1"),
|
||||
("Wednesday", "P4", "9K/Ma1"),
|
||||
("Thursday", "P1", "10K/Ma1"),
|
||||
("Thursday", "P4", "11K/Ma1"),
|
||||
("Friday", "P1", "9K/Ma1"),
|
||||
("Friday", "P3", "10K/Ma1"),
|
||||
],
|
||||
}
|
||||
|
||||
# ─── Student enrollments ───────────────────────────────────────────────────────
|
||||
|
||||
STUDENT_ENROLLMENTS = {
|
||||
"[email protected]": ["10K/Ph1", "10K/Ma1", "10K/CS1"],
|
||||
"[email protected]": ["11K/Ph1", "11K/Ma1"],
|
||||
}
|
||||
|
||||
|
||||
# ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sb_headers() -> Dict:
|
||||
return {
|
||||
"apikey": SERVICE_KEY,
|
||||
"Authorization": f"Bearer {SERVICE_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
"Prefer": "return=representation",
|
||||
}
|
||||
|
||||
|
||||
def _sign_in(email: str, password: str) -> str:
|
||||
r = requests.post(
|
||||
f"{SUPA_URL}/auth/v1/token?grant_type=password",
|
||||
headers={"apikey": SERVICE_KEY, "Content-Type": "application/json"},
|
||||
json={"email": email, "password": password},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def _api(token: str, method: str, path: str, body: Optional[Dict] = None) -> Dict:
|
||||
h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
r = getattr(requests, method)(f"{API_BASE}{path}", headers=h, json=body)
|
||||
try:
|
||||
return r.json()
|
||||
except Exception:
|
||||
return {"_raw": r.text, "_status": r.status_code}
|
||||
|
||||
|
||||
def _get_profile_id(email: str) -> Optional[str]:
|
||||
"""Look up a profile's UUID by email via Supabase service role."""
|
||||
r = requests.get(
|
||||
f"{SUPA_URL}/rest/v1/profiles",
|
||||
headers=_sb_headers(),
|
||||
params={"email": f"eq.{email}", "select": "id", "limit": "1"},
|
||||
)
|
||||
data = r.json() if r.ok else []
|
||||
return data[0]["id"] if data else None
|
||||
|
||||
|
||||
def _get_teacher_timetable_id(profile_id: str) -> Optional[str]:
|
||||
"""Return the Supabase teacher_timetables.id for a given profile."""
|
||||
r = requests.get(
|
||||
f"{SUPA_URL}/rest/v1/teacher_timetables",
|
||||
headers=_sb_headers(),
|
||||
params={"profile_id": f"eq.{profile_id}", "select": "id", "limit": "1"},
|
||||
)
|
||||
data = r.json() if r.ok else []
|
||||
return data[0]["id"] if data else None
|
||||
|
||||
|
||||
def _patch_slot_class_ids(teacher_tt_sb_id: str, class_code_to_id: Dict[str, str]) -> int:
|
||||
"""Update class_id FK on teacher_timetable_slots rows via Supabase service role."""
|
||||
patched = 0
|
||||
for code, class_uuid in class_code_to_id.items():
|
||||
r = requests.patch(
|
||||
f"{SUPA_URL}/rest/v1/teacher_timetable_slots",
|
||||
headers=_sb_headers(),
|
||||
params={
|
||||
"teacher_timetable_id": f"eq.{teacher_tt_sb_id}",
|
||||
"subject_class": f"eq.{code}",
|
||||
},
|
||||
json={"class_id": class_uuid},
|
||||
)
|
||||
if r.ok:
|
||||
patched += 1
|
||||
return patched
|
||||
|
||||
|
||||
# ─── Main seed ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def seed() -> Dict[str, Any]:
|
||||
print("=" * 60)
|
||||
print("KevlarAI — full timetable + class + student seed")
|
||||
print("=" * 60)
|
||||
results: Dict[str, Any] = {}
|
||||
errors: List[str] = []
|
||||
|
||||
# ── [1] Sign in as KevlarAI admin ───────────────────────────────────────
|
||||
print("\n[1] Signing in as [email protected]...")
|
||||
try:
|
||||
admin_token = _sign_in(KEVLARAI_ADMIN_EMAIL, KEVLARAI_ADMIN_PWD)
|
||||
print(" ✓ signed in")
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# ── [2] POST /timetable/setup ─────────────────────────────────────────────
|
||||
print("\n[2] Setting up school timetable (academic year + terms + periods)...")
|
||||
r = _api(admin_token, "post", "/timetable/setup", {
|
||||
"year_start": "2025-09-03",
|
||||
"year_end": "2026-07-17",
|
||||
"terms": TERMS,
|
||||
"periods": PERIODS,
|
||||
})
|
||||
if r.get("status") == "ok" or r.get("school_timetable_id") or r.get("timetable_id"):
|
||||
print(f" ✓ timetable: {r.get('school_timetable_id') or r.get('timetable_id')}")
|
||||
results["setup"] = "ok"
|
||||
else:
|
||||
err = f"timetable/setup: {r}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["setup"] = "error"
|
||||
|
||||
# ── [3] POST /timetable/materialize-periods ───────────────────────────────
|
||||
print("\n[3] Materializing academic_periods (days x periods_template)...")
|
||||
r = _api(admin_token, "post", "/timetable/materialize-periods", None)
|
||||
if r.get("status") == "ok":
|
||||
print(f" ✓ {r.get('created')} periods created across {r.get('academic_days')} academic days")
|
||||
results["materialize_periods"] = "ok"
|
||||
else:
|
||||
err = f"materialize-periods: {r}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["materialize_periods"] = "error"
|
||||
|
||||
# ── [5] Build profile-ID lookup for all teachers + students ───────────────
|
||||
print("\n[3] Resolving profile IDs for teachers and students...")
|
||||
all_emails = (
|
||||
list(TEACHER_SLOTS.keys())
|
||||
+ list(STUDENT_ENROLLMENTS.keys())
|
||||
)
|
||||
profile_ids: Dict[str, str] = {}
|
||||
for email in all_emails:
|
||||
pid = _get_profile_id(email)
|
||||
if pid:
|
||||
profile_ids[email] = pid
|
||||
print(f" ✓ {email} -> {pid[:8]}...")
|
||||
else:
|
||||
print(f" ✗ profile not found for {email}")
|
||||
errors.append(f"profile_not_found: {email}")
|
||||
|
||||
# ── [6] Create classes ────────────────────────────────────────────────────
|
||||
print(f"\n[4] Creating {len(CLASSES)} classes...")
|
||||
results["classes"] = {}
|
||||
class_code_to_id: Dict[str, str] = {}
|
||||
|
||||
for cls in CLASSES:
|
||||
r = _api(admin_token, "post", "/database/timetable/classes", {
|
||||
"name": cls["name"],
|
||||
"class_code": cls["class_code"],
|
||||
"subject": cls["subject"],
|
||||
"year_group": cls["year_group"],
|
||||
"key_stage": cls["key_stage"],
|
||||
"academic_year": "2025-2026",
|
||||
})
|
||||
class_id = r.get("id") or (r.get("class", {}) or {}).get("id")
|
||||
if class_id:
|
||||
class_code_to_id[cls["class_code"]] = class_id
|
||||
results["classes"][cls["class_code"]] = "ok"
|
||||
print(f" ✓ {cls['class_code']} -> {class_id[:8]}...")
|
||||
else:
|
||||
err = f"create class {cls['class_code']}: {r}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["classes"][cls["class_code"]] = "error"
|
||||
time.sleep(0.1)
|
||||
|
||||
# ── [7] Add teachers to their classes ────────────────────────────────────
|
||||
print("\n[5] Adding teachers to classes...")
|
||||
results["class_teachers"] = {}
|
||||
for cls in CLASSES:
|
||||
class_id = class_code_to_id.get(cls["class_code"])
|
||||
teacher_pid = profile_ids.get(cls["teacher"])
|
||||
if not class_id or not teacher_pid:
|
||||
results["class_teachers"][cls["class_code"]] = "skip"
|
||||
continue
|
||||
r = _api(admin_token, "post", f"/database/timetable/classes/{class_id}/teachers", {
|
||||
"teacher_id": teacher_pid,
|
||||
"is_primary": True,
|
||||
})
|
||||
if r.get("status") == "ok" or r.get("id"):
|
||||
print(f" ✓ {cls['teacher'].split('@')[0]} -> {cls['class_code']}")
|
||||
results["class_teachers"][cls["class_code"]] = "ok"
|
||||
else:
|
||||
err = f"add teacher {cls['class_code']}: {r}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["class_teachers"][cls["class_code"]] = "error"
|
||||
time.sleep(0.1)
|
||||
|
||||
# ── [8] Teacher timetable init + slots ────────────────────────────────────
|
||||
print("\n[6] Initialising TeacherTimetable and setting slots for each teacher...")
|
||||
results["init"] = {}
|
||||
results["slots"] = {}
|
||||
teacher_tt_sb_ids: Dict[str, str] = {} # email -> teacher_timetables.id
|
||||
|
||||
for teacher_email, slot_tuples in TEACHER_SLOTS.items():
|
||||
try:
|
||||
teacher_token = _sign_in(teacher_email, PWD_TEACHER)
|
||||
except Exception as e:
|
||||
err = f"login {teacher_email}: {e}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["init"][teacher_email] = "error"
|
||||
results["slots"][teacher_email] = "error"
|
||||
continue
|
||||
|
||||
# 6a: init TeacherTimetable
|
||||
r = _api(teacher_token, "post", "/timetable/init", None)
|
||||
if r.get("status") == "ok":
|
||||
print(f" ✓ init {teacher_email}")
|
||||
results["init"][teacher_email] = "ok"
|
||||
else:
|
||||
print(f" ~ init {teacher_email}: {r.get('message', r)} (may already exist)")
|
||||
results["init"][teacher_email] = "warn"
|
||||
|
||||
# 6b: get timetable_id (Neo4j uuid_string for slot FK)
|
||||
status_r = _api(teacher_token, "get", "/timetable/status", None)
|
||||
timetable_id = status_r.get("timetable_id")
|
||||
if not timetable_id:
|
||||
err = f"no timetable_id for {teacher_email}: {status_r}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["slots"][teacher_email] = "error"
|
||||
continue
|
||||
|
||||
# 6c: save slots (subject_class text; class_id patched separately)
|
||||
slot_list = [
|
||||
{
|
||||
"day_of_week": day,
|
||||
"period_code": code,
|
||||
"subject_class": cls,
|
||||
"start_time": PERIOD_TIMES[code][0],
|
||||
"end_time": PERIOD_TIMES[code][1],
|
||||
}
|
||||
for day, code, cls in slot_tuples
|
||||
]
|
||||
r = _api(teacher_token, "post", "/timetable/slots", {
|
||||
"timetable_id": timetable_id,
|
||||
"slots": slot_list,
|
||||
})
|
||||
if r.get("status") == "ok" or r.get("created") is not None:
|
||||
count = r.get("created") or len(slot_list)
|
||||
print(f" ✓ {teacher_email}: {count} slots")
|
||||
results["slots"][teacher_email] = "ok"
|
||||
else:
|
||||
err = f"slots {teacher_email}: {r}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["slots"][teacher_email] = "error"
|
||||
|
||||
# record Supabase teacher_timetable FK for patching
|
||||
teacher_pid = profile_ids.get(teacher_email)
|
||||
if teacher_pid:
|
||||
tt_sb_id = _get_teacher_timetable_id(teacher_pid)
|
||||
if tt_sb_id:
|
||||
teacher_tt_sb_ids[teacher_email] = tt_sb_id
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
# ── [9] Patch teacher_timetable_slots.class_id ────────────────────────────
|
||||
print("\n[7] Patching class_id onto teacher_timetable_slots...")
|
||||
results["slot_patch"] = {}
|
||||
for teacher_email, slot_tuples in TEACHER_SLOTS.items():
|
||||
tt_sb_id = teacher_tt_sb_ids.get(teacher_email)
|
||||
if not tt_sb_id:
|
||||
results["slot_patch"][teacher_email] = "skip"
|
||||
continue
|
||||
teacher_codes = {cls for _, _, cls in slot_tuples}
|
||||
relevant_map = {code: uid for code, uid in class_code_to_id.items() if code in teacher_codes}
|
||||
n = _patch_slot_class_ids(tt_sb_id, relevant_map)
|
||||
print(f" ✓ {teacher_email}: {n} slots patched")
|
||||
results["slot_patch"][teacher_email] = n
|
||||
|
||||
# ── [10] Enroll students in classes ───────────────────────────────────────
|
||||
print("\n[8] Enrolling students in classes...")
|
||||
results["enrollments"] = {}
|
||||
for student_email, class_codes in STUDENT_ENROLLMENTS.items():
|
||||
student_pid = profile_ids.get(student_email)
|
||||
results["enrollments"][student_email] = {}
|
||||
if not student_pid:
|
||||
results["enrollments"][student_email] = "no_profile"
|
||||
continue
|
||||
for code in class_codes:
|
||||
class_id = class_code_to_id.get(code)
|
||||
if not class_id:
|
||||
results["enrollments"][student_email][code] = "no_class"
|
||||
continue
|
||||
r = _api(admin_token, "post", f"/database/timetable/classes/{class_id}/students", {
|
||||
"student_id": student_pid,
|
||||
})
|
||||
if r.get("status") == "ok" or r.get("id"):
|
||||
print(f" ✓ {student_email.split('@')[0]} -> {code}")
|
||||
results["enrollments"][student_email][code] = "ok"
|
||||
else:
|
||||
err = f"enroll {student_email} -> {code}: {r}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["enrollments"][student_email][code] = "error"
|
||||
time.sleep(0.1)
|
||||
|
||||
# ── [11] Materialize taught lessons ────────────────────────────────────────
|
||||
print("\n[9] Materializing taught lessons for each teacher...")
|
||||
results["materialize"] = {}
|
||||
for teacher_email in TEACHER_SLOTS:
|
||||
try:
|
||||
teacher_token = _sign_in(teacher_email, PWD_TEACHER)
|
||||
except Exception as e:
|
||||
err = f"login {teacher_email}: {e}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
continue
|
||||
r = _api(teacher_token, "post", "/timetable/materialize", None)
|
||||
if r.get("status") == "ok":
|
||||
print(f" ✓ {teacher_email}: {r.get('lessons_upserted', '?')} lessons, "
|
||||
f"{r.get('whiteboard_rooms_created', '?')} rooms")
|
||||
results["materialize"][teacher_email] = "ok"
|
||||
else:
|
||||
err = f"materialize {teacher_email}: {r}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["materialize"][teacher_email] = "error"
|
||||
time.sleep(0.3)
|
||||
|
||||
# ── [12] Neo4j sync (B.10) ────────────────────────────────────────────────
|
||||
print("\n[10] Syncing Neo4j TaughtLesson nodes (B.10)...")
|
||||
r = _api(admin_token, "post", "/timetable/sync-lessons", None)
|
||||
if r.get("status") == "ok":
|
||||
print(f" ✓ Neo4j sync: {r.get('taught_lessons')} lessons, "
|
||||
f"{r.get('teacher_timetables')} timetables, {r.get('slots')} slots")
|
||||
results["neo4j_sync"] = "ok"
|
||||
else:
|
||||
err = f"sync-lessons: {r}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["neo4j_sync"] = "error"
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────
|
||||
print("\n" + "=" * 60)
|
||||
results["success"] = len(errors) == 0
|
||||
results["errors"] = errors
|
||||
if errors:
|
||||
print(f"COMPLETE with {len(errors)} error(s):")
|
||||
for e in errors:
|
||||
print(f" ✗ {e}")
|
||||
else:
|
||||
print("COMPLETE — all steps succeeded")
|
||||
print("=" * 60)
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
print(json.dumps(seed(), indent=2, default=str))
|
||||
@@ -0,0 +1,385 @@
|
||||
"""
|
||||
seed_planned_lessons.py — Create 2-3 planned lessons per teacher across both schools.
|
||||
|
||||
Uses the /lessons/plans API endpoint (POST) to create lesson plans.
|
||||
Each plan is linked to a class, subject, and year group where possible.
|
||||
Plans are idempotent: checks for existing plans by title+subject before creating.
|
||||
|
||||
Tables: planned_lessons, lesson_collaborators, lesson_deliveries
|
||||
|
||||
Run inside ccapi container:
|
||||
python3 -c "from run.initialization.seed_planned_lessons import seed; seed()"
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import requests
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
SUPA_URL = os.environ["SUPABASE_URL"]
|
||||
SERVICE_KEY = os.environ["SERVICE_ROLE_KEY"]
|
||||
API_BASE = os.environ.get("API_BASE_URL", "http://localhost:8000")
|
||||
|
||||
# ─── Passwords (standardized from T4) ────────────────────────────────────────
|
||||
|
||||
PWD_ADMIN = "Admin@Cc2025!"
|
||||
PWD_TEACHER = "Teacher@Cc2025!"
|
||||
|
||||
# ─── Planned lesson templates per school ──────────────────────────────────────
|
||||
# Each entry: (teacher_email, title, subject, year_group, class_code, objectives, activities)
|
||||
|
||||
KEVLARAI_PLANS = [
|
||||
{
|
||||
"teacher": "[email protected]",
|
||||
"title": "Introduction to Forces and Motion",
|
||||
"subject": "Physics",
|
||||
"year_group": "10",
|
||||
"class_code": "10K/Ph1",
|
||||
"objectives": [
|
||||
{"text": "Define force, mass, and acceleration", "bloom": "remember"},
|
||||
{"text": "Apply F=ma to solve simple problems", "bloom": "apply"},
|
||||
],
|
||||
"activities": [
|
||||
{"type": "demo", "description": "Demonstrate forces with spring scales"},
|
||||
{"type": "worksheet", "description": "F=ma calculation practice (10 problems)"},
|
||||
],
|
||||
"duration_minutes": 60,
|
||||
},
|
||||
{
|
||||
"teacher": "[email protected]",
|
||||
"title": "Electric Circuits Basics",
|
||||
"subject": "Physics",
|
||||
"year_group": "11",
|
||||
"class_code": "11K/Ph1",
|
||||
"objectives": [
|
||||
{"text": "Identify series and parallel circuit components", "bloom": "understand"},
|
||||
{"text": "Calculate total resistance in series circuits", "bloom": "apply"},
|
||||
],
|
||||
"activities": [
|
||||
{"type": "lab", "description": "Build series circuit with resistors"},
|
||||
{"type": "quiz", "description": "Resistance calculation quiz (5 questions)"},
|
||||
],
|
||||
"duration_minutes": 60,
|
||||
},
|
||||
{
|
||||
"teacher": "[email protected]",
|
||||
"title": "Quadratic Equations — Factorisation Method",
|
||||
"subject": "Mathematics",
|
||||
"year_group": "10",
|
||||
"class_code": "10K/Ma1",
|
||||
"objectives": [
|
||||
{"text": "Factorise quadratic expressions of the form x²+bx+c", "bloom": "apply"},
|
||||
{"text": "Solve quadratic equations by factorisation", "bloom": "analyse"},
|
||||
],
|
||||
"activities": [
|
||||
{"type": "direct_instruction", "description": "Walk through 3 worked examples"},
|
||||
{"type": "pair_work", "description": "Factorise 8 quadratics with a partner"},
|
||||
],
|
||||
"duration_minutes": 60,
|
||||
},
|
||||
{
|
||||
"teacher": "[email protected]",
|
||||
"title": "Probability — Tree Diagrams",
|
||||
"subject": "Mathematics",
|
||||
"year_group": "11",
|
||||
"class_code": "11K/Ma1",
|
||||
"objectives": [
|
||||
{"text": "Construct tree diagrams for two-stage events", "bloom": "apply"},
|
||||
{"text": "Calculate combined probabilities from tree diagrams", "bloom": "analyse"},
|
||||
],
|
||||
"activities": [
|
||||
{"type": "demo", "description": "Coin toss tree diagram on whiteboard"},
|
||||
{"type": "worksheet", "description": "5 tree diagram probability problems"},
|
||||
],
|
||||
"duration_minutes": 60,
|
||||
},
|
||||
]
|
||||
|
||||
GREENFIELD_PLANS = [
|
||||
{
|
||||
"teacher": "[email protected]",
|
||||
"title": "Waves and Sound",
|
||||
"subject": "Physics",
|
||||
"year_group": "9",
|
||||
"class_code": "9P/Ph1",
|
||||
"objectives": [
|
||||
{"text": "Describe properties of transverse and longitudinal waves", "bloom": "remember"},
|
||||
{"text": "Calculate wave speed using v=fλ", "bloom": "apply"},
|
||||
],
|
||||
"activities": [
|
||||
{"type": "demo", "description": "Slinky wave demonstrations"},
|
||||
{"type": "worksheet", "description": "Wave speed calculations (8 problems)"},
|
||||
],
|
||||
"duration_minutes": 60,
|
||||
},
|
||||
{
|
||||
"teacher": "[email protected]",
|
||||
"title": "Energy Transfers and Conservation",
|
||||
"subject": "Physics",
|
||||
"year_group": "10",
|
||||
"class_code": "10P/Ph2",
|
||||
"objectives": [
|
||||
{"text": "Identify energy stores and transfer pathways", "bloom": "understand"},
|
||||
{"text": "Apply conservation of energy to real-world scenarios", "bloom": "analyse"},
|
||||
],
|
||||
"activities": [
|
||||
{"type": "group_work", "description": "Energy audit of a household"},
|
||||
{"type": "presentation", "description": "Present findings on energy efficiency"},
|
||||
],
|
||||
"duration_minutes": 60,
|
||||
},
|
||||
{
|
||||
"teacher": "[email protected]",
|
||||
"title": "Algebra — Expanding Brackets",
|
||||
"subject": "Mathematics",
|
||||
"year_group": "9",
|
||||
"class_code": "9M/Ma1",
|
||||
"objectives": [
|
||||
{"text": "Expand single brackets: a(b+c)", "bloom": "apply"},
|
||||
{"text": "Expand double brackets: (a+b)(c+d)", "bloom": "analyse"},
|
||||
],
|
||||
"activities": [
|
||||
{"type": "direct_instruction", "description": "Area model for expanding brackets"},
|
||||
{"type": "worksheet", "description": "15 expansion problems (graded difficulty)"},
|
||||
],
|
||||
"duration_minutes": 60,
|
||||
},
|
||||
{
|
||||
"teacher": "[email protected]",
|
||||
"title": "Simultaneous Equations — Elimination Method",
|
||||
"subject": "Mathematics",
|
||||
"year_group": "10",
|
||||
"class_code": "10M/Ma1",
|
||||
"objectives": [
|
||||
{"text": "Solve simultaneous equations by elimination", "bloom": "apply"},
|
||||
{"text": "Choose between substitution and elimination strategically", "bloom": "evaluate"},
|
||||
],
|
||||
"activities": [
|
||||
{"type": "direct_instruction", "description": "Walk through 3 elimination examples"},
|
||||
{"type": "pair_work", "description": "Solve 6 simultaneous equation pairs"},
|
||||
],
|
||||
"duration_minutes": 60,
|
||||
},
|
||||
{
|
||||
"teacher": "[email protected]",
|
||||
"title": "Shakespeare — Macbeth Act 1 Analysis",
|
||||
"subject": "English",
|
||||
"year_group": "9",
|
||||
"class_code": "9En/1",
|
||||
"objectives": [
|
||||
{"text": "Identify key themes in Act 1", "bloom": "understand"},
|
||||
{"text": "Analyse Shakespeare's use of imagery and language", "bloom": "analyse"},
|
||||
],
|
||||
"activities": [
|
||||
{"type": "close_reading", "description": "Close read Act 1, Scene 3 (witches' prophecy)"},
|
||||
{"type": "essay", "description": "Short paragraph: How does Shakespeare create tension?"},
|
||||
],
|
||||
"duration_minutes": 60,
|
||||
},
|
||||
{
|
||||
"teacher": "[email protected]",
|
||||
"title": "The Tudors — Henry VIII's Reforms",
|
||||
"subject": "History",
|
||||
"year_group": "10",
|
||||
"class_code": "10Hs/1",
|
||||
"objectives": [
|
||||
{"text": "Describe Henry VIII's religious reforms", "bloom": "remember"},
|
||||
{"text": "Evaluate the political motivations behind the reforms", "bloom": "evaluate"},
|
||||
],
|
||||
"activities": [
|
||||
{"type": "source_analysis", "description": "Analyze Act of Supremacy 1534"},
|
||||
{"type": "debate", "description": "Was Henry's break with Rome politically necessary?"},
|
||||
],
|
||||
"duration_minutes": 60,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sign_in(email: str, password: str) -> str:
|
||||
r = requests.post(
|
||||
f"{SUPA_URL}/auth/v1/token?grant_type=password",
|
||||
headers={"apikey": SERVICE_KEY, "Content-Type": "application/json"},
|
||||
json={"email": email, "password": password},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def _api(token: str, method: str, path: str, body: Optional[Dict] = None) -> Dict:
|
||||
h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
r = getattr(requests, method)(f"{API_BASE}{path}", headers=h, json=body)
|
||||
try:
|
||||
return r.json()
|
||||
except Exception:
|
||||
return {"_raw": r.text, "_status": r.status_code}
|
||||
|
||||
|
||||
def _get_profile_id(email: str) -> Optional[str]:
|
||||
"""Look up a profile's UUID by email via Supabase service role."""
|
||||
r = requests.get(
|
||||
f"{SUPA_URL}/rest/v1/profiles",
|
||||
headers={
|
||||
"apikey": SERVICE_KEY,
|
||||
"Authorization": f"Bearer {SERVICE_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
params={"email": f"eq.{email}", "select": "id", "limit": "1"},
|
||||
)
|
||||
data = r.json() if r.ok else []
|
||||
return data[0]["id"] if data else None
|
||||
|
||||
|
||||
def _get_class_id(class_code: str, admin_token: str) -> Optional[str]:
|
||||
"""Look up a class UUID by class_code via the API."""
|
||||
r = requests.get(
|
||||
f"{API_BASE}/database/timetable/classes",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
params={"class_code": class_code},
|
||||
)
|
||||
data = r.json() if r.ok else []
|
||||
if isinstance(data, list) and data:
|
||||
return data[0].get("id") or data[0]
|
||||
if isinstance(data, dict):
|
||||
return data.get("id") or data.get("class", {}).get("id")
|
||||
return None
|
||||
|
||||
|
||||
def _existing_plans_for_teacher(token: str, teacher_email: str) -> List[str]:
|
||||
"""Return list of existing plan titles for a teacher (to check idempotency)."""
|
||||
r = requests.get(
|
||||
f"{API_BASE}/lessons/plans",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
if r.ok:
|
||||
plans = r.json().get("plans", [])
|
||||
return [p.get("title", "") for p in plans]
|
||||
return []
|
||||
|
||||
|
||||
# ─── Main seed ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def seed() -> Dict[str, Any]:
|
||||
print("=" * 60)
|
||||
print("Planned lessons seed — both schools")
|
||||
print("=" * 60)
|
||||
results: Dict[str, Any] = {}
|
||||
errors: List[str] = []
|
||||
|
||||
# ── Sign in as both school admins ───────────────────────────────────────
|
||||
print("\n[1] Signing in as school admins...")
|
||||
admin_tokens = {}
|
||||
for school, email, pwd in [
|
||||
("KevlarAI", "[email protected]", PWD_ADMIN),
|
||||
("Greenfield", "[email protected]", PWD_ADMIN),
|
||||
]:
|
||||
try:
|
||||
token = _sign_in(email, pwd)
|
||||
admin_tokens[school] = token
|
||||
print(f" ✓ {school} admin signed in")
|
||||
except Exception as e:
|
||||
print(f" ✗ {school} admin login failed: {e}")
|
||||
errors.append(f"{school}_admin_login: {e}")
|
||||
|
||||
if not admin_tokens:
|
||||
return {"success": False, "error": "No admin tokens obtained"}
|
||||
|
||||
# ── Resolve class IDs ───────────────────────────────────────────────────
|
||||
print("\n[2] Resolving class IDs...")
|
||||
all_class_codes = set()
|
||||
for plans in [KEVLARAI_PLANS, GREENFIELD_PLANS]:
|
||||
for p in plans:
|
||||
if p.get("class_code"):
|
||||
all_class_codes.add(p["class_code"])
|
||||
|
||||
class_code_to_id: Dict[str, str] = {}
|
||||
for code in all_class_codes:
|
||||
# Try KevlarAI first, then Greenfield
|
||||
for school in ["KevlarAI", "Greenfield"]:
|
||||
cid = _get_class_id(code, admin_tokens[school])
|
||||
if cid:
|
||||
class_code_to_id[code] = cid
|
||||
print(f" ✓ {code} -> {cid[:8]}...")
|
||||
break
|
||||
else:
|
||||
print(f" ✗ class not found: {code}")
|
||||
errors.append(f"class_not_found: {code}")
|
||||
|
||||
# ── Seed planned lessons ────────────────────────────────────────────────
|
||||
print("\n[3] Creating planned lessons...")
|
||||
created_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for school, plans in [("KevlarAI", KEVLARAI_PLANS), ("Greenfield", GREENFIELD_PLANS)]:
|
||||
admin_token = admin_tokens[school]
|
||||
print(f"\n [{school}]")
|
||||
|
||||
for plan_spec in plans:
|
||||
teacher_email = plan_spec["teacher"]
|
||||
title = plan_spec["title"]
|
||||
|
||||
# Check idempotency
|
||||
try:
|
||||
teacher_token = _sign_in(teacher_email, PWD_TEACHER)
|
||||
except Exception as e:
|
||||
err = f"login {teacher_email}: {e}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
continue
|
||||
|
||||
existing = _existing_plans_for_teacher(teacher_token, teacher_email)
|
||||
if title in existing:
|
||||
print(f" ~ SKIP (exists): {title}")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Build class_id
|
||||
class_id = None
|
||||
cc = plan_spec.get("class_code")
|
||||
if cc:
|
||||
class_id = class_code_to_id.get(cc)
|
||||
|
||||
body = {
|
||||
"title": title,
|
||||
"subject": plan_spec["subject"],
|
||||
"year_group": plan_spec["year_group"],
|
||||
"estimated_duration_minutes": plan_spec.get("duration_minutes", 60),
|
||||
"objectives": plan_spec["objectives"],
|
||||
"activities": plan_spec["activities"],
|
||||
"status": "draft",
|
||||
"tags": [plan_spec["subject"].lower(), f"yr{plan_spec['year_group']}"],
|
||||
}
|
||||
if class_id:
|
||||
body["class_id"] = class_id
|
||||
|
||||
r = _api(teacher_token, "post", "/lessons/plans", body)
|
||||
plan_id = r.get("id") or (r.get("planned_lesson", {}) or {}).get("id")
|
||||
if plan_id:
|
||||
print(f" ✓ {title} [{plan_id[:8]}...]")
|
||||
created_count += 1
|
||||
else:
|
||||
err = f"create plan '{title}': {r}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
|
||||
time.sleep(0.2)
|
||||
|
||||
# ── Summary ─────────────────────────────────────────────────────────────
|
||||
print("\n" + "=" * 60)
|
||||
results["success"] = len(errors) == 0
|
||||
results["errors"] = errors
|
||||
results["created"] = created_count
|
||||
results["skipped"] = skipped_count
|
||||
if errors:
|
||||
print(f"COMPLETE with {len(errors)} error(s):")
|
||||
for e in errors:
|
||||
print(f" ✗ {e}")
|
||||
else:
|
||||
print(f"COMPLETE — {created_count} created, {skipped_count} skipped (idempotent)")
|
||||
print("=" * 60)
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
print(json.dumps(seed(), indent=2, default=str))
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Compatibility wrapper for the canonical seed environment test mode."""
|
||||
from typing import Any, Dict
|
||||
|
||||
from run.initialization.seed_environment import seed
|
||||
|
||||
|
||||
def seed_test_environment() -> Dict[str, Any]:
|
||||
"""Seed the lightweight 9-user test environment."""
|
||||
return seed(test=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
print(json.dumps(seed_test_environment(), indent=2, default=str))
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Sync Supabase profile users into the central Neo4j cc.users database.
|
||||
|
||||
This script is intentionally idempotent. It defaults to --dry-run so it can be
|
||||
used safely during diagnostics. Use --apply to write/update Neo4j nodes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
SCRIPT_DIR = str(Path(__file__).resolve().parent)
|
||||
if SCRIPT_DIR in sys.path:
|
||||
sys.path.remove(SCRIPT_DIR)
|
||||
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
from neo4j import GraphDatabase
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UserRecord:
|
||||
uuid_string: str
|
||||
user_email: str
|
||||
cc_username: str
|
||||
user_type: str
|
||||
user_name: str
|
||||
display_name: str | None
|
||||
institute_id: str | None
|
||||
institute_name: str | None
|
||||
institute_role: str | None
|
||||
institute_neo4j_uuid_string: str | None
|
||||
user_db_name: str
|
||||
institute_db_name: str | None
|
||||
node_storage_path: str
|
||||
|
||||
|
||||
def load_environment(env_file: str | None) -> None:
|
||||
if env_file:
|
||||
load_dotenv(env_file, override=True)
|
||||
else:
|
||||
load_dotenv(override=False)
|
||||
|
||||
|
||||
def supabase_headers() -> dict[str, str]:
|
||||
key = os.getenv('SERVICE_ROLE_KEY') or os.getenv('ANON_KEY')
|
||||
if not key:
|
||||
raise RuntimeError('SERVICE_ROLE_KEY or ANON_KEY is required')
|
||||
return {'apikey': key, 'Authorization': f'Bearer {key}'}
|
||||
|
||||
|
||||
def fetch_table(table: str, select: str) -> list[dict[str, Any]]:
|
||||
base = os.getenv('SUPABASE_URL')
|
||||
if not base:
|
||||
raise RuntimeError('SUPABASE_URL is required')
|
||||
response = requests.get(
|
||||
f'{base.rstrip("/")}/rest/v1/{table}',
|
||||
headers=supabase_headers(),
|
||||
params={'select': select},
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError(f'Supabase {table} query failed: {response.status_code} {response.text[:500]}')
|
||||
return response.json()
|
||||
|
||||
|
||||
def normalize_uuid(value: str | None) -> str | None:
|
||||
return value.replace('-', '') if value else None
|
||||
|
||||
|
||||
def build_records() -> list[UserRecord]:
|
||||
profiles = fetch_table('profiles', 'id,email,username,full_name,display_name,user_type,metadata,user_db_name,school_db_name')
|
||||
memberships = fetch_table('institute_memberships', 'profile_id,institute_id,role')
|
||||
institutes = fetch_table('institutes', 'id,name,urn,neo4j_uuid_string')
|
||||
membership_by_profile = {m['profile_id']: m for m in memberships}
|
||||
institute_by_id = {i['id']: i for i in institutes}
|
||||
records: list[UserRecord] = []
|
||||
for profile in profiles:
|
||||
profile_id = profile['id']
|
||||
user_type = profile.get('user_type') or 'unknown'
|
||||
uuid_no_dash = normalize_uuid(profile_id)
|
||||
email = profile.get('email') or ''
|
||||
username = profile.get('username') or (email.split('@', 1)[0] if email else uuid_no_dash)
|
||||
user_name = profile.get('full_name') or profile.get('display_name') or username or email or profile_id
|
||||
membership = membership_by_profile.get(profile_id, {})
|
||||
institute = institute_by_id.get(membership.get('institute_id'), {}) if membership else {}
|
||||
institute_uuid = institute.get('neo4j_uuid_string') or normalize_uuid(institute.get('id'))
|
||||
user_db_name = profile.get('user_db_name') or f'cc.users.{user_type}.{uuid_no_dash}'
|
||||
institute_db_name = profile.get('school_db_name') or (f'cc.institutes.{institute_uuid}' if institute_uuid else None)
|
||||
records.append(UserRecord(
|
||||
uuid_string=profile_id,
|
||||
user_email=email,
|
||||
cc_username=username or profile_id,
|
||||
user_type=user_type,
|
||||
user_name=user_name,
|
||||
display_name=profile.get('display_name'),
|
||||
institute_id=membership.get('institute_id') if membership else None,
|
||||
institute_name=institute.get('name') if institute else None,
|
||||
institute_role=membership.get('role') if membership else None,
|
||||
institute_neo4j_uuid_string=institute_uuid,
|
||||
user_db_name=user_db_name,
|
||||
institute_db_name=institute_db_name,
|
||||
node_storage_path=f'neo4j://{user_db_name}/User/{profile_id}',
|
||||
))
|
||||
return records
|
||||
|
||||
|
||||
def neo4j_driver():
|
||||
url = os.getenv('APP_BOLT_URL')
|
||||
username = os.getenv('USER_NEO4J')
|
||||
password = os.getenv('PASSWORD_NEO4J')
|
||||
if not url or not username or not password:
|
||||
raise RuntimeError('APP_BOLT_URL, USER_NEO4J, and PASSWORD_NEO4J are required')
|
||||
return GraphDatabase.driver(url, auth=(username, password))
|
||||
|
||||
|
||||
def ensure_schema(session) -> None:
|
||||
statements = [
|
||||
'CREATE CONSTRAINT user_uuid_unique IF NOT EXISTS FOR (u:User) REQUIRE u.uuid_string IS UNIQUE',
|
||||
'CREATE INDEX user_email_index IF NOT EXISTS FOR (u:User) ON (u.user_email)',
|
||||
'CREATE INDEX user_username_index IF NOT EXISTS FOR (u:User) ON (u.cc_username)',
|
||||
'CREATE INDEX user_type_index IF NOT EXISTS FOR (u:User) ON (u.user_type)',
|
||||
'CREATE INDEX user_institute_index IF NOT EXISTS FOR (u:User) ON (u.institute_id)',
|
||||
'CREATE CONSTRAINT institute_uuid_unique IF NOT EXISTS FOR (i:Institute) REQUIRE i.uuid_string IS UNIQUE',
|
||||
]
|
||||
for statement in statements:
|
||||
session.run(statement).consume()
|
||||
|
||||
|
||||
def merge_user(session, record: UserRecord) -> None:
|
||||
labels = ':User'
|
||||
if record.user_type == 'teacher':
|
||||
labels = ':User:Teacher'
|
||||
elif record.user_type == 'student':
|
||||
labels = ':User:Student'
|
||||
session.run(
|
||||
f"""
|
||||
MERGE (u{labels} {{uuid_string: $uuid_string}})
|
||||
SET u.user_email = $user_email,
|
||||
u.cc_username = $cc_username,
|
||||
u.user_type = $user_type,
|
||||
u.user_name = $user_name,
|
||||
u.display_name = $display_name,
|
||||
u.institute_id = $institute_id,
|
||||
u.institute_name = $institute_name,
|
||||
u.institute_role = $institute_role,
|
||||
u.institute_neo4j_uuid_string = $institute_neo4j_uuid_string,
|
||||
u.user_db_name = $user_db_name,
|
||||
u.institute_db_name = $institute_db_name,
|
||||
u.node_storage_path = $node_storage_path,
|
||||
u.merged = true,
|
||||
u.source = 'supabase.profiles',
|
||||
u.synced_at = datetime()
|
||||
""",
|
||||
**record.__dict__,
|
||||
).consume()
|
||||
if record.institute_neo4j_uuid_string:
|
||||
session.run(
|
||||
"""
|
||||
MATCH (u:User {uuid_string: $uuid_string})
|
||||
MERGE (i:Institute {uuid_string: $institute_neo4j_uuid_string})
|
||||
SET i.supabase_id = $institute_id,
|
||||
i.name = $institute_name
|
||||
MERGE (u)-[r:MEMBER_OF]->(i)
|
||||
SET r.role = $institute_role,
|
||||
r.synced_at = datetime()
|
||||
""",
|
||||
**record.__dict__,
|
||||
).consume()
|
||||
|
||||
|
||||
def verify(session) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
result['users'] = session.run('MATCH (u:User) RETURN count(u) AS c').single()['c']
|
||||
result['teachers'] = session.run("MATCH (u:User {user_type: 'teacher'}) RETURN count(u) AS c").single()['c']
|
||||
result['students'] = session.run("MATCH (u:User {user_type: 'student'}) RETURN count(u) AS c").single()['c']
|
||||
result['bad_users'] = session.run("""
|
||||
MATCH (u:User)
|
||||
WHERE u.uuid_string IS NULL OR u.user_email IS NULL OR u.cc_username IS NULL
|
||||
OR u.user_type IS NULL OR u.user_name IS NULL OR u.user_db_name IS NULL
|
||||
RETURN count(u) AS c
|
||||
""").single()['c']
|
||||
result['duplicate_uuids'] = session.run("""
|
||||
MATCH (u:User)
|
||||
WITH u.uuid_string AS uuid, count(*) AS c
|
||||
WHERE c > 1
|
||||
RETURN count(*) AS c
|
||||
""").single()['c']
|
||||
result['memberships'] = session.run('MATCH (:User)-[r:MEMBER_OF]->(:Institute) RETURN count(r) AS c').single()['c']
|
||||
result['institutes'] = session.run('MATCH (i:Institute) RETURN count(i) AS c').single()['c']
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--env-file', default=None)
|
||||
parser.add_argument('--database', default='cc.users')
|
||||
parser.add_argument('--apply', action='store_true', help='Write to Neo4j. Defaults to dry-run.')
|
||||
args = parser.parse_args()
|
||||
load_environment(args.env_file)
|
||||
records = build_records()
|
||||
by_type: dict[str, int] = {}
|
||||
for record in records:
|
||||
by_type[record.user_type] = by_type.get(record.user_type, 0) + 1
|
||||
print(f'Supabase source records: {len(records)} {by_type}')
|
||||
if not args.apply:
|
||||
for record in records[:5]:
|
||||
print(f'DRY RUN {record.uuid_string} {record.user_email} -> {record.user_db_name}')
|
||||
print('Dry run only. Re-run with --apply to write Neo4j cc.users.')
|
||||
return 0
|
||||
with neo4j_driver() as driver:
|
||||
with driver.session(database=args.database) as session:
|
||||
ensure_schema(session)
|
||||
for record in records:
|
||||
merge_user(session, record)
|
||||
result = verify(session)
|
||||
print(f'Neo4j verification: {result}')
|
||||
expected = {
|
||||
'users': len(records),
|
||||
'teachers': by_type.get('teacher', 0),
|
||||
'students': by_type.get('student', 0),
|
||||
'bad_users': 0,
|
||||
'duplicate_uuids': 0,
|
||||
'memberships': len(records),
|
||||
'institutes': 2,
|
||||
}
|
||||
for key, value in expected.items():
|
||||
if result.get(key) != value:
|
||||
raise RuntimeError(f'Verification failed for {key}: expected {value}, got {result.get(key)}')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -9,6 +9,15 @@ from routers.msgraph import router_onenote
|
||||
from routers.dev.tests import timetable_test
|
||||
from routers.database.init import entity_init, calendar, timetables, curriculum, get_data, schools
|
||||
from routers.database.tools import get_nodes, get_nodes_and_edges, tldraw_filesystem, tldraw_supabase_storage, get_events, calendar_structure_router, default_nodes_router, worker_structure_router
|
||||
from routers.database.tools.graph_tree_router import router as graph_tree_router
|
||||
from routers.database.tools.user_init_router import router as user_init_router
|
||||
from routers.database.tools.timetable_builder_router import router as timetable_builder_router
|
||||
from routers.database.tools.school_router import router as school_router
|
||||
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
|
||||
@@ -29,6 +38,9 @@ from routers import provisioning as provisioning_router
|
||||
from routers.transcribe.sessions import router as sessions_router
|
||||
from routers.transcribe.canvas_events import router as canvas_events_router
|
||||
from routers.transcribe.keywords import router as keywords_router
|
||||
from routers.me.bootstrap_router import router as me_bootstrap_router
|
||||
from routers import tlsync_token as tlsync_token_router
|
||||
from routers.exam import router as exam_router
|
||||
|
||||
def register_routes(app: FastAPI):
|
||||
logger.info("Starting to register routes...")
|
||||
@@ -47,12 +59,28 @@ def register_routes(app: FastAPI):
|
||||
app.include_router(entity_init.router, prefix="/database/entity", tags=["Entity"])
|
||||
app.include_router(calendar.router, prefix="/database/calendar", tags=["Calendar"])
|
||||
app.include_router(schools.router, prefix="/database/schools", tags=["Schools"])
|
||||
from routers.database.timetable.timetables import router as timetable_router
|
||||
app.include_router(timetables.router, prefix="/database/timetables", tags=["Timetables"])
|
||||
app.include_router(timetable_router, prefix="/database/timetable/timetables", tags=["Timetables"])
|
||||
app.include_router(curriculum.router, prefix="/database/curriculum", tags=["Curriculum"])
|
||||
|
||||
# Navigation Routes
|
||||
app.include_router(calendar_structure_router.router, prefix="/database/calendar-structure", tags=["Calendar"])
|
||||
app.include_router(worker_structure_router.router, prefix="/database/worker-structure", tags=["Worker"])
|
||||
|
||||
# Session/bootstrap routes
|
||||
app.include_router(me_bootstrap_router, prefix="/me", tags=["Bootstrap"])
|
||||
|
||||
# Graph navigation
|
||||
app.include_router(graph_tree_router, prefix="/graph", tags=["Graph Navigation"])
|
||||
app.include_router(user_init_router, prefix="/user", tags=["User"])
|
||||
app.include_router(timetable_builder_router, prefix="/timetable", tags=["Timetable"])
|
||||
app.include_router(school_router, prefix="/school", tags=["School"])
|
||||
app.include_router(classes_router, prefix="/database/timetable/classes", tags=["Classes"])
|
||||
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
|
||||
@@ -104,6 +132,12 @@ def register_routes(app: FastAPI):
|
||||
# Provisioning Routes
|
||||
app.include_router(provisioning_router.router)
|
||||
|
||||
# TLSync auth token route
|
||||
app.include_router(tlsync_token_router.router, prefix="/api/tlsync", tags=["TLSync"])
|
||||
|
||||
# Exam-marker Routes (as-user Supabase, RLS-enforced; spec §4)
|
||||
app.include_router(exam_router, prefix="/api/exam", tags=["Exam"])
|
||||
|
||||
# Transcription Routes (CIS Phase 1)
|
||||
app.include_router(sessions_router, prefix="/transcribe", tags=["Transcription Sessions"])
|
||||
app.include_router(canvas_events_router, prefix="/transcribe", tags=["Transcription Canvas Events"])
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# ClassroomCopilot Startup Script
|
||||
# Usage: ./start.sh [start_mode]
|
||||
# start_mode options: infra, demo-school, demo-users, gais-data, full, dev, prod
|
||||
# start_mode options: infra, seed, seed-test, gais-data, full, dev, prod
|
||||
|
||||
set -e
|
||||
|
||||
@@ -14,10 +14,10 @@ show_help() {
|
||||
echo ""
|
||||
echo "Start modes:"
|
||||
echo " infra - Setup infrastructure (Neo4j schema, calendar, Supabase buckets)"
|
||||
echo " demo-school - Create demo school (KevlarAI)"
|
||||
echo " demo-users - Create demo users"
|
||||
echo " seed - Seed canonical full environment (20 school users)"
|
||||
echo " seed-test - Seed lightweight test environment (9 school users)"
|
||||
echo " gais-data - Import GAIS data (Edubase, etc.)"
|
||||
echo " full - Run full initialization (infra → demo-school → demo-users → gais-data)"
|
||||
echo " full - Run full initialization (infra → seed)"
|
||||
echo " nuke - 💥 NUKE Redis - Clear all queue data for fresh start"
|
||||
echo " dev - Run development server with auto-reload"
|
||||
echo " prod - Run production server (for Docker/containerized deployment)"
|
||||
@@ -25,8 +25,8 @@ show_help() {
|
||||
echo "Examples:"
|
||||
echo " ./start.sh # Run in dev mode (default)"
|
||||
echo " ./start.sh infra # Setup infrastructure"
|
||||
echo " ./start.sh demo-school # Create demo school"
|
||||
echo " ./start.sh demo-users # Create demo users"
|
||||
echo " ./start.sh seed # Seed canonical full environment"
|
||||
echo " ./start.sh seed-test # Seed lightweight test environment"
|
||||
echo " ./start.sh gais-data # Import GAIS data"
|
||||
echo " ./start.sh full # Run full initialization"
|
||||
echo " ./start.sh full --yes # Run full initialization without prompts"
|
||||
@@ -133,54 +133,32 @@ run_infra() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to run demo school creation
|
||||
run_demo_school() {
|
||||
print_status "Running demo school creation mode..."
|
||||
print_status "This will create the KevlarAI demo school."
|
||||
# Function to run canonical environment seed
|
||||
run_seed() {
|
||||
local mode=${1:-seed}
|
||||
if [[ "$mode" == "seed-test" ]]; then
|
||||
print_status "Running lightweight seed-test mode (9 school users)..."
|
||||
else
|
||||
print_status "Running canonical seed mode (20 school users)..."
|
||||
fi
|
||||
|
||||
# Check if we should proceed
|
||||
if [[ "$AUTO_YES" != true ]]; then
|
||||
read -p "Do you want to continue with demo school creation? (y/N): " -n 1 -r
|
||||
read -p "Do you want to continue with $mode? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
print_status "Demo school creation cancelled."
|
||||
print_status "$mode cancelled."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
print_status "Starting demo school creation process..."
|
||||
$PYTHON_CMD main.py --mode demo-school
|
||||
print_status "Starting $mode process..."
|
||||
$PYTHON_CMD main.py --mode "$mode"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
print_success "Demo school creation completed successfully!"
|
||||
print_success "$mode completed successfully!"
|
||||
else
|
||||
print_error "Demo school creation failed!"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to run demo users creation
|
||||
run_demo_users() {
|
||||
print_status "Running demo users creation mode..."
|
||||
print_status "This will create demo users for testing."
|
||||
|
||||
# Check if we should proceed
|
||||
if [[ "$AUTO_YES" != true ]]; then
|
||||
read -p "Do you want to continue with demo users creation? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
print_status "Demo users creation cancelled."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
print_status "Starting demo users creation process..."
|
||||
$PYTHON_CMD main.py --mode demo-users
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
print_success "Demo users creation completed successfully!"
|
||||
else
|
||||
print_error "Demo users creation failed!"
|
||||
print_error "$mode failed!"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
@@ -274,7 +252,7 @@ except Exception as e:
|
||||
|
||||
# Function to run full initialization (all steps in order)
|
||||
run_full() {
|
||||
print_status "Running full initialization (infra → demo-school → demo-users → gais-data)..."
|
||||
print_status "Running full initialization (infra → seed)..."
|
||||
|
||||
# Single confirmation for the whole flow
|
||||
if [[ "$AUTO_YES" != true ]]; then
|
||||
@@ -289,14 +267,8 @@ run_full() {
|
||||
# Run infra
|
||||
run_infra || { print_error "Full init aborted during infra."; exit 1; }
|
||||
|
||||
# Run demo school
|
||||
run_demo_school || { print_error "Full init aborted during demo-school."; exit 1; }
|
||||
|
||||
# Run demo users
|
||||
run_demo_users || { print_error "Full init aborted during demo-users."; exit 1; }
|
||||
|
||||
# Run GAIS data import
|
||||
run_gais_data || { print_error "Full init aborted during gais-data."; exit 1; }
|
||||
# Run canonical full seed
|
||||
run_seed seed || { print_error "Full init aborted during seed."; exit 1; }
|
||||
|
||||
print_success "Full initialization completed successfully!"
|
||||
}
|
||||
@@ -383,11 +355,11 @@ main() {
|
||||
"infra")
|
||||
run_infra
|
||||
;;
|
||||
"demo-school")
|
||||
run_demo_school
|
||||
"seed")
|
||||
run_seed seed
|
||||
;;
|
||||
"demo-users")
|
||||
run_demo_users
|
||||
"seed-test")
|
||||
run_seed seed-test
|
||||
;;
|
||||
"gais-data")
|
||||
run_gais_data
|
||||
@@ -406,7 +378,7 @@ main() {
|
||||
;;
|
||||
*)
|
||||
print_error "Invalid start mode: $START_MODE"
|
||||
print_status "Valid modes: infra, demo-school, demo-users, gais-data, nuke, dev, prod"
|
||||
print_status "Valid modes: infra, seed, seed-test, gais-data, full, nuke, dev, prod"
|
||||
print_status "Usage: ./start.sh [start_mode]"
|
||||
print_status "Use './start.sh --help' for more information"
|
||||
exit 1
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import os
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
def _supabase_headers():
|
||||
key = os.getenv('SERVICE_ROLE_KEY') or os.getenv('ANON_KEY')
|
||||
assert key, 'SERVICE_ROLE_KEY or ANON_KEY must be set for Supabase integration tests'
|
||||
return {
|
||||
'apikey': key,
|
||||
'Authorization': f'Bearer {key}',
|
||||
'Prefer': 'count=exact',
|
||||
}
|
||||
|
||||
|
||||
def _rest_count(table: str) -> int:
|
||||
supabase_url = os.getenv('SUPABASE_URL')
|
||||
assert supabase_url, 'SUPABASE_URL must be set'
|
||||
response = requests.get(
|
||||
f'{supabase_url.rstrip("/")}/rest/v1/{table}',
|
||||
headers=_supabase_headers(),
|
||||
params={'select': 'id'},
|
||||
timeout=15,
|
||||
)
|
||||
assert response.status_code in (200, 206), response.text[:500]
|
||||
content_range = response.headers.get('content-range', '')
|
||||
assert '/' in content_range, f'missing exact content-range count for {table}: {content_range!r}'
|
||||
return int(content_range.rsplit('/', 1)[1])
|
||||
|
||||
|
||||
def test_dev_environment_points_at_dev_supabase():
|
||||
assert os.getenv('SUPABASE_URL') == 'http://192.168.0.94:8000'
|
||||
|
||||
|
||||
def test_dev_api_health_endpoint_is_healthy():
|
||||
health_url = os.getenv('API_HEALTH_URL', 'http://192.168.0.64:18000/health')
|
||||
response = requests.get(health_url, timeout=15)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload['status'] == 'healthy'
|
||||
|
||||
runtime = payload['runtime']
|
||||
assert runtime['api_runtime_role'] == 'dev'
|
||||
assert runtime['start_mode'] == 'dev'
|
||||
assert runtime['app_environment'] == 'development'
|
||||
assert runtime['environment'] == 'development'
|
||||
assert runtime['backend_dev_mode'] is True
|
||||
assert runtime['compose_project'] == 'api-dev'
|
||||
assert runtime['supabase_url_host'] == '192.168.0.94'
|
||||
|
||||
assert payload['services']['supabase']['status'] == 'healthy'
|
||||
assert payload['services']['supabase']['url_host'] == '192.168.0.94'
|
||||
assert payload['services']['redis']['status'] == 'healthy'
|
||||
assert payload['services']['redis']['environment'] == 'dev'
|
||||
assert payload['services']['redis']['database'] == 0
|
||||
|
||||
|
||||
def test_supabase_dev_seed_core_counts():
|
||||
assert _rest_count('profiles') == 21
|
||||
assert _rest_count('institute_memberships') == 21
|
||||
assert _rest_count('institutes') == 2
|
||||
|
||||
|
||||
def test_supabase_dev_seed_timetable_counts():
|
||||
assert _rest_count('classes') == 17
|
||||
assert _rest_count('taught_lessons') == 1462
|
||||
|
||||
|
||||
def test_runtime_identity_does_not_expose_secret_values():
|
||||
health_url = os.getenv('API_HEALTH_URL', 'http://192.168.0.64:18000/health')
|
||||
response = requests.get(health_url, timeout=15)
|
||||
assert response.status_code == 200
|
||||
payload_text = response.text
|
||||
for secret_name in ('SERVICE_ROLE_KEY', 'ANON_KEY', 'SUPABASE_JWT_SECRET', 'REDIS_PASSWORD'):
|
||||
secret_value = os.getenv(secret_name)
|
||||
if secret_value:
|
||||
assert secret_value not in payload_text
|
||||
|
||||
runtime = response.json()['runtime']
|
||||
assert 'supabase_url' not in runtime
|
||||
assert 'service_role_key' not in runtime
|
||||
assert 'anon_key' not in runtime
|
||||
@@ -0,0 +1,301 @@
|
||||
"""Tests for /api/exam/batches, /marks, /scans (card S4-6).
|
||||
|
||||
FakeSupabase emulates RLS by pre-filtering the visible store slice (same approach as
|
||||
test_exam_templates). Service-role helpers (name resolution, storage) are monkeypatched; live
|
||||
as-user RLS is covered by the .94 smoke.
|
||||
"""
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import routers.exam.batches as batches_mod
|
||||
from routers.exam.batches import router
|
||||
from routers.exam.dependencies import ExamContext
|
||||
|
||||
|
||||
TEACHER = "00000000-0000-0000-0000-000000000001"
|
||||
INST_A = "10000000-0000-0000-0000-000000000001"
|
||||
TPL = "t-1"
|
||||
CLASS = "c-1"
|
||||
|
||||
|
||||
class FakeResult:
|
||||
def __init__(self, data):
|
||||
self.data = data
|
||||
|
||||
|
||||
class FakeQuery:
|
||||
def __init__(self, store, table):
|
||||
self.store = store
|
||||
self.table = table
|
||||
self.rows = list(store.get(table, []))
|
||||
self._filters = []
|
||||
self._op = None
|
||||
self._payload = None
|
||||
self._limit = None
|
||||
|
||||
def select(self, *_a, **_k):
|
||||
self._op = "select"; return self
|
||||
|
||||
def insert(self, payload):
|
||||
self._op = "insert"; self._payload = payload; return self
|
||||
|
||||
def update(self, payload):
|
||||
self._op = "update"; self._payload = payload; return self
|
||||
|
||||
def upsert(self, payload):
|
||||
self._op = "upsert"; self._payload = payload; return self
|
||||
|
||||
def delete(self):
|
||||
self._op = "delete"; return self
|
||||
|
||||
def eq(self, k, v):
|
||||
self._filters.append(("eq", k, v)); self.rows = [r for r in self.rows if r.get(k) == v]; return self
|
||||
|
||||
def neq(self, k, v):
|
||||
self._filters.append(("neq", k, v)); self.rows = [r for r in self.rows if r.get(k) != v]; return self
|
||||
|
||||
def in_(self, k, vals):
|
||||
vals = set(vals); self._filters.append(("in", k, vals)); self.rows = [r for r in self.rows if r.get(k) in vals]; return self
|
||||
|
||||
def order(self, *_a, **_k):
|
||||
return self
|
||||
|
||||
def limit(self, n):
|
||||
self._limit = n; return self
|
||||
|
||||
def _match(self, row):
|
||||
for op, k, v in self._filters:
|
||||
if op == "eq" and row.get(k) != v:
|
||||
return False
|
||||
if op == "neq" and row.get(k) == v:
|
||||
return False
|
||||
if op == "in" and row.get(k) not in v:
|
||||
return False
|
||||
return True
|
||||
|
||||
def execute(self):
|
||||
backing = self.store.setdefault(self.table, [])
|
||||
if self._op in ("insert", "upsert"):
|
||||
payloads = self._payload if isinstance(self._payload, list) else [self._payload]
|
||||
out = []
|
||||
for p in payloads:
|
||||
row = dict(p)
|
||||
if self._op == "upsert" and row.get("id") is not None:
|
||||
existing = next((r for r in backing if r.get("id") == row["id"]), None)
|
||||
if existing:
|
||||
existing.update(row); out.append(existing); continue
|
||||
row.setdefault("id", f"gen-{self.table}-{len(backing)}")
|
||||
backing.append(row); out.append(row)
|
||||
return FakeResult(out)
|
||||
if self._op == "update":
|
||||
out = []
|
||||
for r in backing:
|
||||
if self._match(r):
|
||||
r.update(self._payload); out.append(r)
|
||||
return FakeResult(out)
|
||||
if self._op == "delete":
|
||||
self.store[self.table] = [r for r in backing if not self._match(r)]
|
||||
return FakeResult([r for r in backing if self._match(r)])
|
||||
rows = self.rows[: self._limit] if self._limit is not None else self.rows
|
||||
return FakeResult(rows)
|
||||
|
||||
|
||||
class FakeSupabase:
|
||||
def __init__(self, store):
|
||||
self.store = store
|
||||
|
||||
def table(self, name):
|
||||
return FakeQuery(self.store, name)
|
||||
|
||||
|
||||
def make_client(store, user_id=TEACHER, institute_ids=(INST_A,)):
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/exam")
|
||||
from routers.exam.dependencies import get_exam_context
|
||||
app.dependency_overrides[get_exam_context] = lambda: ExamContext(user_id, "tok", FakeSupabase(store), list(institute_ids))
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def base_store(**extra):
|
||||
store = {"exam_templates": [{"id": TPL, "institute_id": INST_A, "teacher_id": TEACHER, "status": "draft"}]}
|
||||
store.update(extra)
|
||||
return store
|
||||
|
||||
|
||||
# ─── batches ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_create_batch_no_class():
|
||||
store = base_store()
|
||||
c = make_client(store)
|
||||
r = c.post("/api/exam/batches", json={"template_id": TPL, "title": "Mock 1"})
|
||||
assert r.status_code == 200
|
||||
b = r.json()
|
||||
assert b["teacher_id"] == TEACHER and b["institute_id"] == INST_A
|
||||
assert b["status"] == "open" and b["submission_count"] == 0
|
||||
|
||||
|
||||
def test_create_batch_template_404():
|
||||
c = make_client(base_store())
|
||||
assert c.post("/api/exam/batches", json={"template_id": "nope"}).status_code == 404
|
||||
|
||||
|
||||
def test_create_batch_seeds_roster_as_absent(monkeypatch):
|
||||
monkeypatch.setattr(batches_mod, "resolve_student_names",
|
||||
lambda ids: {sid: f"Name {sid}" for sid in ids})
|
||||
store = base_store(class_students=[
|
||||
{"class_id": CLASS, "student_id": "s1", "status": "active"},
|
||||
{"class_id": CLASS, "student_id": "s2", "status": "active"},
|
||||
{"class_id": CLASS, "student_id": "s3", "status": "inactive"}, # excluded
|
||||
])
|
||||
c = make_client(store)
|
||||
r = c.post("/api/exam/batches", json={"template_id": TPL, "class_id": CLASS})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["submission_count"] == 2
|
||||
subs = store["student_submissions"]
|
||||
assert {s["student_id"] for s in subs} == {"s1", "s2"}
|
||||
assert all(s["status"] == "absent" for s in subs)
|
||||
assert all(s["student_name"].startswith("Name ") for s in subs)
|
||||
|
||||
|
||||
def test_list_batches_excludes_archived():
|
||||
store = base_store(marking_batches=[
|
||||
{"id": "b1", "template_id": TPL, "institute_id": INST_A, "teacher_id": TEACHER, "status": "open"},
|
||||
{"id": "b2", "template_id": TPL, "institute_id": INST_A, "teacher_id": TEACHER, "status": "archived"},
|
||||
])
|
||||
c = make_client(store)
|
||||
ids = {b["id"] for b in c.get("/api/exam/batches").json()["batches"]}
|
||||
assert ids == {"b1"}
|
||||
|
||||
|
||||
# ─── queue / results / csv (A7) ──────────────────────────────────────────────
|
||||
|
||||
def _batch_with_cohort():
|
||||
return base_store(
|
||||
marking_batches=[{"id": "b1", "template_id": TPL, "institute_id": INST_A, "teacher_id": TEACHER, "status": "open"}],
|
||||
exam_questions=[
|
||||
{"id": "q1", "template_id": TPL, "label": "01", "max_marks": 3, "order": 0},
|
||||
{"id": "q2", "template_id": TPL, "label": "02", "max_marks": 5, "order": 1},
|
||||
],
|
||||
student_submissions=[
|
||||
{"id": "sub1", "batch_id": "b1", "student_id": "s1", "student_name": "Alice", "status": "complete"},
|
||||
{"id": "sub2", "batch_id": "b1", "student_id": "s2", "student_name": "Bob", "status": "absent"},
|
||||
],
|
||||
mark_entries=[
|
||||
{"id": "m1", "batch_id": "b1", "submission_id": "sub1", "question_id": "q1", "awarded_marks": 2},
|
||||
{"id": "m2", "batch_id": "b1", "submission_id": "sub1", "question_id": "q2", "awarded_marks": 4},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_queue_progress_counts():
|
||||
c = make_client(_batch_with_cohort())
|
||||
body = c.get("/api/exam/batches/b1/queue").json()
|
||||
assert body["progress"]["total"] == 2
|
||||
assert body["progress"]["absent"] == 1 and body["progress"]["complete"] == 1
|
||||
counts = {s["id"]: s["mark_entry_count"] for s in body["submissions"]}
|
||||
assert counts == {"sub1": 2, "sub2": 0}
|
||||
|
||||
|
||||
def test_results_includes_absent_with_blank(monkeypatch):
|
||||
c = make_client(_batch_with_cohort())
|
||||
body = c.get("/api/exam/batches/b1/results").json()
|
||||
by_id = {r["submission_id"]: r for r in body["results"]}
|
||||
assert by_id["sub1"]["total"] == 6
|
||||
assert by_id["sub2"]["total"] is None # absent → blank total (A7)
|
||||
assert set(by_id["sub2"]["marks"].values()) == {None}
|
||||
assert {r["student_name"] for r in body["results"]} == {"Alice", "Bob"} # absent NOT dropped
|
||||
|
||||
|
||||
def test_csv_includes_absent_row():
|
||||
c = make_client(_batch_with_cohort())
|
||||
text = c.get("/api/exam/batches/b1/csv").text
|
||||
lines = [l for l in text.strip().splitlines() if l]
|
||||
assert lines[0].split(",")[:3] == ["student_name", "student_id", "status"]
|
||||
assert "01" in lines[0] and "02" in lines[0] # question labels as columns
|
||||
assert any(l.startswith("Bob,") and ",absent," in l for l in lines) # absent present
|
||||
assert len(lines) == 3 # header + 2 students (incl. absent)
|
||||
|
||||
|
||||
# ─── marks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_upsert_mark_derives_batch_and_roundtrips():
|
||||
store = _batch_with_cohort()
|
||||
c = make_client(store)
|
||||
r = c.put("/api/exam/marks/mk-1", json={"submission_id": "sub1", "question_id": "q1", "awarded_marks": 3})
|
||||
assert r.status_code == 200
|
||||
row = r.json()
|
||||
assert row["batch_id"] == "b1" and row["awarded_marks"] == 3 and row["id"] == "mk-1"
|
||||
# upsert again → same id updated, not duplicated
|
||||
c.put("/api/exam/marks/mk-1", json={"submission_id": "sub1", "question_id": "q1", "awarded_marks": 1})
|
||||
assert sum(1 for m in store["mark_entries"] if m["id"] == "mk-1") == 1
|
||||
|
||||
|
||||
def test_upsert_mark_flips_absent_submission_to_marking():
|
||||
store = _batch_with_cohort() # sub2 starts 'absent'
|
||||
c = make_client(store)
|
||||
c.put("/api/exam/marks/mk-2", json={"submission_id": "sub2", "question_id": "q1", "awarded_marks": 2})
|
||||
sub2 = next(s for s in store["student_submissions"] if s["id"] == "sub2")
|
||||
assert sub2["status"] == "marking"
|
||||
# results now show a real total for the (formerly absent) marked student
|
||||
res = {r["submission_id"]: r for r in c.get("/api/exam/batches/b1/results").json()["results"]}
|
||||
assert res["sub2"]["total"] == 2
|
||||
|
||||
|
||||
def test_upsert_mark_submission_404():
|
||||
c = make_client(_batch_with_cohort())
|
||||
assert c.put("/api/exam/marks/mk-x", json={"submission_id": "nope", "question_id": "q1", "awarded_marks": 1}).status_code == 404
|
||||
|
||||
|
||||
# ─── scans (E3 guards) ───────────────────────────────────────────────────────
|
||||
|
||||
def _batch_store():
|
||||
return base_store(marking_batches=[{"id": "b1", "template_id": TPL, "institute_id": INST_A, "teacher_id": TEACHER, "status": "open"}],
|
||||
student_submissions=[{"id": "sub1", "batch_id": "b1", "student_id": "s1", "status": "absent"}])
|
||||
|
||||
|
||||
def test_scan_rejects_non_pdf_mime():
|
||||
c = make_client(_batch_store())
|
||||
r = c.post("/api/exam/batches/b1/scans", files={"file": ("x.png", b"\x89PNG", "image/png")}, data={"matching_method": "manual"})
|
||||
assert r.status_code == 415
|
||||
|
||||
|
||||
def test_scan_rejects_spoofed_pdf():
|
||||
c = make_client(_batch_store())
|
||||
r = c.post("/api/exam/batches/b1/scans", files={"file": ("x.pdf", b"not really a pdf", "application/pdf")}, data={"matching_method": "manual"})
|
||||
assert r.status_code == 415 # magic-byte sniff
|
||||
|
||||
|
||||
def test_scan_rejects_oversize(monkeypatch):
|
||||
monkeypatch.setattr(batches_mod, "MAX_SCAN_BYTES", 8)
|
||||
c = make_client(_batch_store())
|
||||
r = c.post("/api/exam/batches/b1/scans", files={"file": ("x.pdf", b"%PDF-" + b"0" * 100, "application/pdf")}, data={"matching_method": "manual"})
|
||||
assert r.status_code == 413
|
||||
|
||||
|
||||
class _FakeStorage:
|
||||
def upload_file(self, *a, **k):
|
||||
return None
|
||||
|
||||
|
||||
def test_scan_manual_match_happy(monkeypatch):
|
||||
monkeypatch.setattr(batches_mod, "StorageAdmin", _FakeStorage)
|
||||
store = _batch_store()
|
||||
c = make_client(store)
|
||||
r = c.post("/api/exam/batches/b1/scans",
|
||||
files={"file": ("x.pdf", b"%PDF-1.7 minimal", "application/pdf")},
|
||||
data={"matching_method": "manual", "student_id": "s1"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "matched"
|
||||
assert store["student_submissions"][0]["status"] == "matched"
|
||||
assert store["student_submissions"][0]["scan_url"].startswith("exam-submissions/b1/")
|
||||
|
||||
|
||||
def test_scan_denied_for_non_owner(monkeypatch):
|
||||
monkeypatch.setattr(batches_mod, "StorageAdmin", _FakeStorage)
|
||||
store = _batch_store()
|
||||
c = make_client(store, user_id="someone-else")
|
||||
r = c.post("/api/exam/batches/b1/scans", files={"file": ("x.pdf", b"%PDF-1.7", "application/pdf")}, data={"matching_method": "manual"})
|
||||
assert r.status_code == 403
|
||||
@@ -0,0 +1,334 @@
|
||||
"""Tests for the /api/exam/templates router (card S4-5).
|
||||
|
||||
Mirrors the FakeSupabase + dependency_overrides pattern from test_me_bootstrap.py. The
|
||||
ExamContext dependency is overridden with an in-memory fake, so these tests exercise the
|
||||
router's auth/ownership/institute logic without a live Supabase — the as-user RLS itself is
|
||||
verified separately against .94 (see the evidence note).
|
||||
"""
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import routers.exam.templates as templates_mod
|
||||
from routers.exam.templates import router
|
||||
from routers.exam.dependencies import ExamContext, get_exam_context
|
||||
|
||||
|
||||
TEACHER = "00000000-0000-0000-0000-000000000001"
|
||||
OTHER_TEACHER = "00000000-0000-0000-0000-000000000002"
|
||||
INST_A = "10000000-0000-0000-0000-000000000001"
|
||||
INST_B = "10000000-0000-0000-0000-000000000002"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_projection(monkeypatch):
|
||||
"""Record projection scheduling and never touch Neo4j/service-role in unit tests."""
|
||||
calls = []
|
||||
monkeypatch.setattr(templates_mod, "project_template_safe", lambda tid: calls.append(tid))
|
||||
monkeypatch.setattr(templates_mod, "project_template", lambda tid: {"exam_code": "X", "questions": 1})
|
||||
return calls
|
||||
|
||||
|
||||
# ─── in-memory fake supabase ─────────────────────────────────────────────────
|
||||
|
||||
class FakeResult:
|
||||
def __init__(self, data):
|
||||
self.data = data
|
||||
|
||||
|
||||
class FakeQuery:
|
||||
"""Models the subset of the supabase-py builder the router uses, against a row list.
|
||||
|
||||
Crucially it emulates RLS: the backing store is pre-filtered to the rows the caller can
|
||||
see, so cross-institute / non-owner access naturally reads back empty (→ 404)."""
|
||||
|
||||
def __init__(self, store, table):
|
||||
self.store = store
|
||||
self.table = table
|
||||
self.rows = list(store.get(table, []))
|
||||
self._filters = []
|
||||
self._op = None
|
||||
self._payload = None
|
||||
self._limit = None
|
||||
|
||||
def select(self, *_a, **_k):
|
||||
self._op = "select"
|
||||
return self
|
||||
|
||||
def insert(self, payload):
|
||||
self._op = "insert"
|
||||
self._payload = payload
|
||||
return self
|
||||
|
||||
def update(self, payload):
|
||||
self._op = "update"
|
||||
self._payload = payload
|
||||
return self
|
||||
|
||||
def delete(self):
|
||||
self._op = "delete"
|
||||
return self
|
||||
|
||||
def eq(self, key, value):
|
||||
self._filters.append(("eq", key, value))
|
||||
self.rows = [r for r in self.rows if r.get(key) == value]
|
||||
return self
|
||||
|
||||
def neq(self, key, value):
|
||||
self._filters.append(("neq", key, value))
|
||||
self.rows = [r for r in self.rows if r.get(key) != value]
|
||||
return self
|
||||
|
||||
def in_(self, key, values):
|
||||
values = set(values)
|
||||
self._filters.append(("in", key, values))
|
||||
self.rows = [r for r in self.rows if r.get(key) in values]
|
||||
return self
|
||||
|
||||
def order(self, *_a, **_k):
|
||||
return self
|
||||
|
||||
def limit(self, n):
|
||||
self._limit = n
|
||||
return self
|
||||
|
||||
def _matches(self, row):
|
||||
for op, key, value in self._filters:
|
||||
if op == "eq" and row.get(key) != value:
|
||||
return False
|
||||
if op == "neq" and row.get(key) == value:
|
||||
return False
|
||||
return True
|
||||
|
||||
def execute(self):
|
||||
backing = self.store.setdefault(self.table, [])
|
||||
if self._op == "insert":
|
||||
payloads = self._payload if isinstance(self._payload, list) else [self._payload]
|
||||
inserted = []
|
||||
for p in payloads:
|
||||
row = dict(p)
|
||||
row.setdefault("id", f"gen-{self.table}-{len(backing)}")
|
||||
backing.append(row)
|
||||
inserted.append(row)
|
||||
return FakeResult(inserted)
|
||||
if self._op == "update":
|
||||
updated = []
|
||||
for row in backing:
|
||||
if self._matches(row):
|
||||
row.update(self._payload)
|
||||
updated.append(row)
|
||||
return FakeResult(updated)
|
||||
if self._op == "delete":
|
||||
kept = [r for r in backing if not self._matches(r)]
|
||||
removed = [r for r in backing if self._matches(r)]
|
||||
self.store[self.table] = kept
|
||||
return FakeResult(removed)
|
||||
# select
|
||||
rows = self.rows[: self._limit] if self._limit is not None else self.rows
|
||||
return FakeResult(rows)
|
||||
|
||||
|
||||
class FakeSupabase:
|
||||
def __init__(self, store):
|
||||
self.store = store
|
||||
|
||||
def table(self, name):
|
||||
return FakeQuery(self.store, name)
|
||||
|
||||
|
||||
def make_client(user_id=TEACHER, institute_ids=(INST_A,), store=None):
|
||||
store = store if store is not None else {}
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/exam")
|
||||
|
||||
def _ctx():
|
||||
return ExamContext(user_id, "fake-token", FakeSupabase(store), list(institute_ids))
|
||||
|
||||
app.dependency_overrides[get_exam_context] = _ctx
|
||||
return TestClient(app), store
|
||||
|
||||
|
||||
# ─── tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_requires_auth_when_not_overridden():
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/exam")
|
||||
# No dependency override → real SupabaseBearer runs and rejects the missing token.
|
||||
resp = TestClient(app).get("/api/exam/templates")
|
||||
assert resp.status_code in (401, 403) # unauthenticated, not processed
|
||||
|
||||
|
||||
def test_create_template_sets_owner_and_institute():
|
||||
client, store = make_client()
|
||||
resp = client.post("/api/exam/templates", json={"title": "AQA Physics 1H", "subject": "Physics"})
|
||||
assert resp.status_code == 200
|
||||
row = resp.json()
|
||||
assert row["title"] == "AQA Physics 1H"
|
||||
assert row["teacher_id"] == TEACHER
|
||||
assert row["institute_id"] == INST_A
|
||||
assert row["status"] == "draft"
|
||||
|
||||
|
||||
def test_create_template_rejects_foreign_institute():
|
||||
client, _ = make_client(institute_ids=(INST_A,))
|
||||
resp = client.post("/api/exam/templates", json={"title": "X", "institute_id": INST_B})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_create_template_requires_institute_when_ambiguous():
|
||||
client, _ = make_client(institute_ids=(INST_A, INST_B))
|
||||
resp = client.post("/api/exam/templates", json={"title": "X"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_list_excludes_archived_by_default():
|
||||
store = {
|
||||
"exam_templates": [
|
||||
{"id": "t1", "title": "live", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER},
|
||||
{"id": "t2", "title": "gone", "status": "archived", "institute_id": INST_A, "teacher_id": TEACHER},
|
||||
]
|
||||
}
|
||||
client, _ = make_client(store=store)
|
||||
titles = [t["title"] for t in client.get("/api/exam/templates").json()["templates"]]
|
||||
assert titles == ["live"]
|
||||
all_titles = {t["title"] for t in client.get("/api/exam/templates?include_archived=true").json()["templates"]}
|
||||
assert all_titles == {"live", "gone"}
|
||||
|
||||
|
||||
def test_get_template_bundles_children():
|
||||
store = {
|
||||
"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}],
|
||||
"exam_questions": [{"id": "q1", "template_id": "t1", "label": "01", "order": 0}],
|
||||
"exam_response_areas": [{"id": "r1", "template_id": "t1", "question_id": "q1", "page": 1}],
|
||||
"exam_boundaries": [{"id": "b1", "template_id": "t1", "page_index": 0, "y": 10}],
|
||||
}
|
||||
client, _ = make_client(store=store)
|
||||
body = client.get("/api/exam/templates/t1").json()
|
||||
assert len(body["questions"]) == 1
|
||||
assert len(body["response_areas"]) == 1
|
||||
assert len(body["boundaries"]) == 1
|
||||
|
||||
|
||||
def test_get_other_institute_template_is_404():
|
||||
# RLS emulation: a template the caller can't see isn't in their visible store slice.
|
||||
store = {"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_B, "teacher_id": OTHER_TEACHER}]}
|
||||
client, _ = make_client(institute_ids=(INST_A,), store=store)
|
||||
# The fake store doesn't model institute filtering on read, so simulate the RLS-hidden row
|
||||
# by querying an id the caller's store doesn't contain.
|
||||
assert client.get("/api/exam/templates/does-not-exist").status_code == 404
|
||||
|
||||
|
||||
def test_put_replace_persists_children_with_client_ids():
|
||||
store = {"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}]}
|
||||
client, store = make_client(store=store)
|
||||
payload = {
|
||||
"questions": [{"id": "q-uuid-1", "label": "01.1", "order": 0, "max_marks": 3}],
|
||||
"response_areas": [{"id": "r-uuid-1", "question_id": "q-uuid-1", "page": 1, "bounds": {"x": 1}, "kind": "response"}],
|
||||
"boundaries": [{"id": "b-uuid-1", "page_index": 0, "y": 12.5}],
|
||||
}
|
||||
resp = client.put("/api/exam/templates/t1", json=payload)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["questions"][0]["id"] == "q-uuid-1" # client UUID preserved (Neo4j join key)
|
||||
assert body["response_areas"][0]["id"] == "r-uuid-1"
|
||||
assert body["boundaries"][0]["id"] == "b-uuid-1"
|
||||
|
||||
|
||||
def test_put_replace_clears_previous_children():
|
||||
store = {
|
||||
"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}],
|
||||
"exam_questions": [{"id": "old", "template_id": "t1", "label": "stale", "order": 0}],
|
||||
}
|
||||
client, store = make_client(store=store)
|
||||
client.put("/api/exam/templates/t1", json={"questions": [{"id": "new", "label": "fresh", "order": 0}]})
|
||||
ids = {q["id"] for q in store["exam_questions"]}
|
||||
assert ids == {"new"} # old row replaced, not appended
|
||||
|
||||
|
||||
def test_put_replace_blocked_when_marks_recorded():
|
||||
# Re-saving the structure after marking began would cascade-delete mark_entries → guard 409.
|
||||
store = {
|
||||
"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}],
|
||||
"marking_batches": [{"id": "b1", "template_id": "t1", "teacher_id": TEACHER, "institute_id": INST_A}],
|
||||
"mark_entries": [{"id": "m1", "batch_id": "b1", "submission_id": "s1", "question_id": "q1", "awarded_marks": 2}],
|
||||
"exam_questions": [{"id": "q1", "template_id": "t1", "label": "01", "order": 0}],
|
||||
}
|
||||
client, store = make_client(store=store)
|
||||
r = client.put("/api/exam/templates/t1", json={"questions": [{"id": "q2", "label": "new", "order": 0}]})
|
||||
assert r.status_code == 409
|
||||
# original question untouched (no destructive delete happened)
|
||||
assert {q["id"] for q in store["exam_questions"]} == {"q1"}
|
||||
|
||||
|
||||
def test_put_replace_allowed_when_batch_has_no_marks():
|
||||
store = {
|
||||
"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}],
|
||||
"marking_batches": [{"id": "b1", "template_id": "t1", "teacher_id": TEACHER, "institute_id": INST_A}],
|
||||
"mark_entries": [],
|
||||
}
|
||||
client, _ = make_client(store=store)
|
||||
assert client.put("/api/exam/templates/t1", json={"questions": [{"id": "q2", "label": "new", "order": 0}]}).status_code == 200
|
||||
|
||||
|
||||
def test_put_replace_denied_for_non_owner():
|
||||
store = {"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": OTHER_TEACHER}]}
|
||||
# Caller is a colleague in the same institute (can read), but not the owner → 403.
|
||||
client, _ = make_client(user_id=TEACHER, institute_ids=(INST_A,), store=store)
|
||||
resp = client.put("/api/exam/templates/t1", json={"questions": []})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_archive_soft_deletes():
|
||||
store = {"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}]}
|
||||
client, store = make_client(store=store)
|
||||
resp = client.delete("/api/exam/templates/t1")
|
||||
assert resp.status_code == 200
|
||||
assert store["exam_templates"][0]["status"] == "archived" # not hard-deleted
|
||||
|
||||
|
||||
def test_patch_question_updates_fields():
|
||||
store = {"exam_questions": [{"id": "q1", "template_id": "t1", "label": "01", "max_marks": 0}]}
|
||||
client, store = make_client(store=store)
|
||||
resp = client.patch("/api/exam/questions/q1", json={"max_marks": 5, "spec_ref": "8.1.2"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["max_marks"] == 5
|
||||
assert store["exam_questions"][0]["spec_ref"] == "8.1.2"
|
||||
|
||||
|
||||
def test_patch_question_missing_is_404():
|
||||
client, _ = make_client(store={"exam_questions": []})
|
||||
assert client.patch("/api/exam/questions/nope", json={"max_marks": 1}).status_code == 404
|
||||
|
||||
|
||||
def test_patch_question_empty_body_is_400():
|
||||
store = {"exam_questions": [{"id": "q1", "template_id": "t1", "label": "01"}]}
|
||||
client, _ = make_client(store=store)
|
||||
assert client.patch("/api/exam/questions/q1", json={}).status_code == 400
|
||||
|
||||
|
||||
# ─── Neo4j projection (S4-7) ─────────────────────────────────────────────────
|
||||
|
||||
def test_put_schedules_projection(_stub_projection):
|
||||
store = {"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}]}
|
||||
client, _ = make_client(store=store)
|
||||
client.put("/api/exam/templates/t1", json={"questions": []})
|
||||
assert _stub_projection == ["t1"] # projection enqueued for the saved template
|
||||
|
||||
|
||||
def test_neo4j_sync_owner_runs():
|
||||
store = {"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}]}
|
||||
client, _ = make_client(store=store)
|
||||
r = client.post("/api/exam/templates/t1/neo4j-sync")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["projection"]["exam_code"] == "X"
|
||||
|
||||
|
||||
def test_neo4j_sync_non_owner_403():
|
||||
store = {"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": OTHER_TEACHER}]}
|
||||
client, _ = make_client(user_id=TEACHER, institute_ids=(INST_A,), store=store)
|
||||
assert client.post("/api/exam/templates/t1/neo4j-sync").status_code == 403
|
||||
|
||||
|
||||
def test_neo4j_sync_404():
|
||||
client, _ = make_client(store={"exam_templates": []})
|
||||
assert client.post("/api/exam/templates/does-not-exist/neo4j-sync").status_code == 404
|
||||
@@ -0,0 +1,281 @@
|
||||
import os
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from routers.me.bootstrap_router import router, auth_scheme
|
||||
from modules.database.services.bootstrap_service import BootstrapService
|
||||
|
||||
|
||||
USER_ID = "00000000-0000-0000-0000-000000000001"
|
||||
INST_A = "10000000-0000-0000-0000-000000000001"
|
||||
INST_B = "10000000-0000-0000-0000-000000000002"
|
||||
|
||||
|
||||
class FakeResult:
|
||||
def __init__(self, data):
|
||||
self.data = data
|
||||
|
||||
|
||||
class FakeQuery:
|
||||
def __init__(self, rows):
|
||||
self.rows = list(rows)
|
||||
self._single = False
|
||||
self._limit = None
|
||||
|
||||
def select(self, *_args, **_kwargs):
|
||||
return self
|
||||
|
||||
def eq(self, key, value):
|
||||
self.rows = [row for row in self.rows if row.get(key) == value]
|
||||
return self
|
||||
|
||||
def in_(self, key, values):
|
||||
values = set(values)
|
||||
self.rows = [row for row in self.rows if row.get(key) in values]
|
||||
return self
|
||||
|
||||
def order(self, *_args, **_kwargs):
|
||||
return self
|
||||
|
||||
def limit(self, count):
|
||||
self._limit = count
|
||||
return self
|
||||
|
||||
def single(self):
|
||||
self._single = True
|
||||
return self
|
||||
|
||||
def execute(self):
|
||||
rows = self.rows[: self._limit] if self._limit is not None else self.rows
|
||||
if self._single:
|
||||
return FakeResult(rows[0] if rows else None)
|
||||
return FakeResult(rows)
|
||||
|
||||
|
||||
class FakeSupabase:
|
||||
def __init__(self, tables):
|
||||
self.tables = tables
|
||||
|
||||
def table(self, name):
|
||||
return FakeQuery(self.tables.get(name, []))
|
||||
|
||||
|
||||
def profile(user_type="teacher", school_id=None):
|
||||
return {
|
||||
"id": USER_ID,
|
||||
"email": "[email protected]",
|
||||
"full_name": "Example Teacher",
|
||||
"display_name": None,
|
||||
"user_type": user_type,
|
||||
"school_id": school_id,
|
||||
}
|
||||
|
||||
|
||||
def institute(id_=INST_A, name="Example School", status="active"):
|
||||
return {
|
||||
"id": id_,
|
||||
"name": name,
|
||||
"urn": "123456",
|
||||
"website": "https://school.example",
|
||||
"address": {"town": "Testville"},
|
||||
"metadata": {"internal_note": "should remain institute metadata only"},
|
||||
"status": status,
|
||||
"neo4j_uuid_string": "neo-a" if id_ == INST_A else "neo-b",
|
||||
}
|
||||
|
||||
|
||||
def membership(institute_id=INST_A, role="teacher"):
|
||||
return {"profile_id": USER_ID, "institute_id": institute_id, "role": role}
|
||||
|
||||
|
||||
def service(tables, graph_probe=None):
|
||||
return BootstrapService(FakeSupabase(tables), graph_probe=graph_probe or (lambda **_: {"available": True, "projection_state": "ready"}))
|
||||
|
||||
|
||||
def build(credentials=None, tables=None, graph_probe=None):
|
||||
credentials = credentials or {"sub": USER_ID, "email": "[email protected]"}
|
||||
tables = tables or {"profiles": [profile()]}
|
||||
return service(tables, graph_probe).build(credentials)
|
||||
|
||||
|
||||
def test_supabase_client_for_user_uses_access_token_authorization(monkeypatch):
|
||||
from modules.database.supabase.utils import client as client_module
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeOptions:
|
||||
def __init__(self, **kwargs):
|
||||
captured["options_kwargs"] = kwargs
|
||||
|
||||
def fake_create_client(url, key, options=None):
|
||||
captured["url"] = url
|
||||
captured["key"] = key
|
||||
captured["options"] = options
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setenv("SUPABASE_URL", "http://supabase.test")
|
||||
monkeypatch.setenv("ANON_KEY", "anon-key")
|
||||
monkeypatch.setattr(client_module, "SyncClientOptions", FakeOptions)
|
||||
monkeypatch.setattr(client_module, "create_client", fake_create_client)
|
||||
|
||||
anon = client_module.SupabaseAnonClient.for_user("user-token")
|
||||
|
||||
assert anon.access_token == "user-token"
|
||||
assert captured["url"] == "http://supabase.test"
|
||||
assert captured["key"] == "anon-key"
|
||||
assert captured["options_kwargs"]["headers"] == {
|
||||
"apikey": "anon-key",
|
||||
"Authorization": "Bearer user-token",
|
||||
}
|
||||
|
||||
|
||||
def test_no_school_bootstrap_requires_school_membership_but_allows_canvas():
|
||||
payload = build(tables={"profiles": [profile()]})
|
||||
|
||||
assert payload["school_status"] == "no_school"
|
||||
assert payload["active_institute"]["source"] == "none"
|
||||
assert payload["memberships"] == []
|
||||
assert payload["permissions"]["can_create_school"] is True
|
||||
assert payload["permissions"]["can_use_canvas"] is True
|
||||
assert payload["onboarding"]["next_step"] == "create_or_join_school"
|
||||
assert "school_membership" in payload["onboarding"]["required"]
|
||||
|
||||
|
||||
def test_single_membership_becomes_active_and_uses_supabase_calendar_timetable_status():
|
||||
payload = build(tables={
|
||||
"profiles": [profile()],
|
||||
"institute_memberships": [membership()],
|
||||
"institutes": [institute()],
|
||||
"academic_years": [{"id": "ay-1", "institute_id": INST_A, "is_current": True}],
|
||||
"academic_terms": [{"id": "term-1", "institute_id": INST_A}],
|
||||
"school_timetables": [{"id": "school-tt-1", "institute_id": INST_A}],
|
||||
"teacher_timetables": [{"id": "teacher-tt-1", "institute_id": INST_A, "teacher_profile_id": USER_ID}],
|
||||
"teacher_timetable_slots": [
|
||||
{"id": "slot-1", "teacher_timetable_id": "teacher-tt-1"},
|
||||
{"id": "slot-2", "teacher_timetable_id": "teacher-tt-1"},
|
||||
],
|
||||
})
|
||||
|
||||
assert payload["school_status"] == "member"
|
||||
assert payload["active_institute"] == {"id": INST_A, "source": "single_membership", "membership_role": "teacher"}
|
||||
assert payload["calendar_status"] == {
|
||||
"available": True,
|
||||
"academic_year_count": 1,
|
||||
"term_count": 1,
|
||||
"current_academic_year_id": "ay-1",
|
||||
"needs_setup": False,
|
||||
}
|
||||
assert payload["timetable_status"] == {
|
||||
"available": True,
|
||||
"teacher_timetable_id": "teacher-tt-1",
|
||||
"slot_count": 2,
|
||||
"needs_setup": False,
|
||||
}
|
||||
assert payload["onboarding"]["next_step"] == "ready"
|
||||
|
||||
|
||||
def test_multiple_memberships_require_selection_when_no_profile_school_id_matches():
|
||||
payload = build(tables={
|
||||
"profiles": [profile()],
|
||||
"institute_memberships": [membership(INST_A, "teacher"), membership(INST_B, "department_head")],
|
||||
"institutes": [institute(INST_A, "Alpha"), institute(INST_B, "Beta")],
|
||||
})
|
||||
|
||||
assert payload["school_status"] == "multi_school_needs_selection"
|
||||
assert payload["active_institute"] == {"id": None, "source": "none", "membership_role": None}
|
||||
assert payload["onboarding"]["next_step"] == "select_school"
|
||||
assert "active_school_selection" in payload["onboarding"]["required"]
|
||||
|
||||
|
||||
def test_school_admin_permissions_and_onboarding_invite_staff_after_calendar_timetable_ready():
|
||||
payload = build(tables={
|
||||
"profiles": [profile(user_type="school_admin", school_id=INST_A)],
|
||||
"institute_memberships": [membership(INST_A, "school_admin")],
|
||||
"institutes": [institute()],
|
||||
"academic_years": [{"id": "ay-1", "institute_id": INST_A, "is_current": True}],
|
||||
"academic_terms": [{"id": "term-1", "institute_id": INST_A}],
|
||||
"teacher_timetables": [{"id": "teacher-tt-1", "institute_id": INST_A, "teacher_profile_id": USER_ID}],
|
||||
"teacher_timetable_slots": [{"id": "slot-1", "teacher_timetable_id": "teacher-tt-1"}],
|
||||
})
|
||||
|
||||
assert payload["school_status"] == "school_admin"
|
||||
perms = payload["permissions"]
|
||||
assert perms["can_manage_school"] is True
|
||||
assert perms["can_manage_calendar"] is True
|
||||
assert perms["can_manage_timetable"] is True
|
||||
assert perms["can_invite_staff"] is True
|
||||
assert perms["can_view_student_data"] is True
|
||||
assert payload["onboarding"]["next_step"] == "invite_staff"
|
||||
|
||||
|
||||
def test_platform_admin_uses_admin_profiles_without_relying_on_profile_super_admin_type():
|
||||
payload = build(credentials={"sub": USER_ID, "email": "[email protected]"}, tables={
|
||||
"profiles": [profile(user_type="teacher")],
|
||||
"admin_profiles": [{"id": USER_ID, "admin_role": "owner", "is_super_admin": True}],
|
||||
})
|
||||
|
||||
assert payload["profile"]["user_type"] == "platform_admin"
|
||||
assert payload["school_status"] == "platform_admin"
|
||||
assert payload["permissions"]["platform_admin"] is True
|
||||
assert payload["permissions"]["platform_super_admin"] is True
|
||||
assert payload["permissions"]["can_create_school"] is True
|
||||
|
||||
|
||||
def test_platform_admin_membership_role_does_not_reduce_platform_permissions():
|
||||
payload = build(credentials={"sub": USER_ID, "email": "[email protected]"}, tables={
|
||||
"profiles": [profile(user_type="student")],
|
||||
"admin_profiles": [{"id": USER_ID, "admin_role": "owner", "is_super_admin": True}],
|
||||
"institute_memberships": [membership(INST_A, "student")],
|
||||
"institutes": [institute()],
|
||||
})
|
||||
|
||||
assert payload["school_status"] == "platform_admin"
|
||||
assert payload["permissions"]["platform_admin"] is True
|
||||
assert payload["permissions"]["can_manage_school"] is True
|
||||
assert payload["permissions"]["can_manage_calendar"] is True
|
||||
assert payload["permissions"]["can_view_student_data"] is True
|
||||
|
||||
|
||||
def test_neo4j_probe_failure_does_not_block_supabase_bootstrap_state():
|
||||
def failing_probe(**_kwargs):
|
||||
raise RuntimeError("connection refused with host details that must not leak")
|
||||
|
||||
payload = build(tables={
|
||||
"profiles": [profile()],
|
||||
"institute_memberships": [membership()],
|
||||
"institutes": [institute()],
|
||||
}, graph_probe=failing_probe)
|
||||
|
||||
assert payload["school_status"] == "member"
|
||||
assert payload["active_institute"]["id"] == INST_A
|
||||
assert payload["graph_status"]["available"] is False
|
||||
assert payload["graph_status"]["projection_state"] == "error"
|
||||
assert payload["graph_status"]["needs_rebuild"] is True
|
||||
assert payload["graph_status"]["error_code"] == "neo4j_unavailable"
|
||||
assert "connection refused" not in str(payload)
|
||||
|
||||
|
||||
def test_route_is_authenticated_and_does_not_return_raw_auth_metadata(monkeypatch):
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/me")
|
||||
app.dependency_overrides[auth_scheme] = lambda: {"sub": USER_ID, "email": "[email protected]", "app_metadata": {"secret": "nope"}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"routers.me.bootstrap_router.build_bootstrap_response",
|
||||
lambda credentials: {
|
||||
"profile": {"id": USER_ID, "email": credentials["email"], "display_name": "Example", "user_type": "teacher", "school_id": None},
|
||||
"memberships": [],
|
||||
"active_institute": {"id": None, "source": "none", "membership_role": None},
|
||||
"permissions": {"platform_admin": False},
|
||||
"school_status": "no_school",
|
||||
"onboarding": {"next_step": "create_or_join_school", "required": [], "optional": [], "message": "Create or join a school."},
|
||||
"calendar_status": {"available": False, "academic_year_count": 0, "term_count": 0, "current_academic_year_id": None, "needs_setup": True},
|
||||
"timetable_status": {"available": False, "teacher_timetable_id": None, "slot_count": 0, "needs_setup": True},
|
||||
"graph_status": {"available": False, "user_db": None, "institute_db": None, "projection_state": "unknown", "needs_rebuild": True, "last_checked_at": "2026-05-28T00:00:00Z", "error_code": None},
|
||||
},
|
||||
)
|
||||
|
||||
response = TestClient(app).get("/me/bootstrap")
|
||||
assert response.status_code == 200
|
||||
assert "app_metadata" not in response.text
|
||||
assert "secret" not in response.text
|
||||
@@ -0,0 +1,34 @@
|
||||
import os
|
||||
|
||||
from neo4j import GraphDatabase
|
||||
|
||||
|
||||
def test_cc_users_database_is_populated_from_seed_profiles():
|
||||
url = os.getenv('APP_BOLT_URL')
|
||||
user = os.getenv('USER_NEO4J')
|
||||
password = os.getenv('PASSWORD_NEO4J')
|
||||
assert url and user and password
|
||||
with GraphDatabase.driver(url, auth=(user, password)) as driver:
|
||||
with driver.session(database='cc.users') as session:
|
||||
result = session.run('''
|
||||
CALL { MATCH (u:User) RETURN count(u) AS users }
|
||||
CALL { MATCH (u:User {user_type: 'teacher'}) RETURN count(u) AS teachers }
|
||||
CALL { MATCH (u:User {user_type: 'student'}) RETURN count(u) AS students }
|
||||
CALL { MATCH (:User)-[r:MEMBER_OF]->(:Institute) RETURN count(r) AS memberships }
|
||||
CALL { MATCH (i:Institute) RETURN count(i) AS institutes }
|
||||
CALL {
|
||||
MATCH (bad:User)
|
||||
WHERE bad.uuid_string IS NULL OR bad.user_email IS NULL OR bad.cc_username IS NULL
|
||||
OR bad.user_type IS NULL OR bad.user_name IS NULL OR bad.user_db_name IS NULL
|
||||
RETURN count(bad) AS bad_users
|
||||
}
|
||||
RETURN users, teachers, students, memberships, institutes, bad_users
|
||||
''').single()
|
||||
assert dict(result) == {
|
||||
'users': 21,
|
||||
'teachers': 15,
|
||||
'students': 6,
|
||||
'memberships': 21,
|
||||
'institutes': 2,
|
||||
'bad_users': 0,
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def test_classes_school_students_route_registered_before_dynamic_class_id():
|
||||
from routers.database.tools.classes_router import router
|
||||
|
||||
paths = [route.path for route in router.routes]
|
||||
assert paths.index('/school/students') < paths.index('/{class_id}')
|
||||
|
||||
|
||||
def test_supabase_anon_for_user_sets_user_authorization_header(monkeypatch):
|
||||
from modules.database.supabase.utils import client as client_module
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_create_client(url, key, options=None):
|
||||
captured['url'] = url
|
||||
captured['key'] = key
|
||||
captured['options'] = options
|
||||
return object()
|
||||
|
||||
monkeypatch.setenv('SUPABASE_URL', 'http://supabase.test')
|
||||
monkeypatch.setenv('ANON_KEY', 'anon-key')
|
||||
monkeypatch.setattr(client_module, 'create_client', fake_create_client)
|
||||
|
||||
client_module.SupabaseAnonClient.for_user('Bearer user-jwt')
|
||||
|
||||
assert captured['key'] == 'anon-key'
|
||||
assert captured['options'].headers['apikey'] == 'anon-key'
|
||||
assert captured['options'].headers['Authorization'] == 'Bearer user-jwt'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('token', ['', ' '])
|
||||
def test_supabase_anon_for_user_requires_token(token):
|
||||
from modules.database.supabase.utils.client import SupabaseAnonClient
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
SupabaseAnonClient.for_user(token)
|
||||
|
||||
|
||||
def test_tldraw_malformed_snapshot_falls_back_to_default():
|
||||
from routers.database.tools import tldraw_supabase_storage as storage
|
||||
|
||||
assert not storage._is_valid_tldraw_snapshot({'document': {}, 'session': {}})
|
||||
default = storage.create_default_tldraw_content()
|
||||
assert storage._is_valid_tldraw_snapshot(default)
|
||||
assert default['document']['schema']['schemaVersion'] == 2
|
||||
|
||||
|
||||
def test_tldraw_rejects_cross_tenant_snapshot_db(monkeypatch):
|
||||
from routers.database.tools import tldraw_supabase_storage as storage
|
||||
|
||||
monkeypatch.setattr(storage, '_user_scope', lambda user_id: {
|
||||
'user_id': user_id,
|
||||
'teacher_db': f"cc.users.teacher.{user_id.replace('-', '')}",
|
||||
'institute_id': 'school-1',
|
||||
'institute_db': 'cc.institutes.allowed',
|
||||
'curriculum_db': 'cc.institutes.allowed.curriculum',
|
||||
})
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
storage._authorize_snapshot_path(
|
||||
'cc.public.snapshots/School/other-school',
|
||||
'cc.institutes.other',
|
||||
{'sub': 'user-1'},
|
||||
write=False,
|
||||
)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_graph_node_children_rejects_unscoped_db(monkeypatch):
|
||||
from routers.database.tools import graph_tree_router
|
||||
|
||||
monkeypatch.setattr(graph_tree_router, '_allowed_neo4j_dbs', lambda user_id, email: {'cc.users.teacher.user1'})
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
graph_tree_router._require_allowed_neo4j_db(
|
||||
'cc.institutes.not-mine',
|
||||
'SubjectClass',
|
||||
'classes',
|
||||
'user-1',
|
||||
'[email protected]',
|
||||
)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_graph_node_children_allows_global_calendar_only():
|
||||
from routers.database.tools import graph_tree_router
|
||||
|
||||
graph_tree_router._require_allowed_neo4j_db(
|
||||
'classroomcopilot',
|
||||
'CalendarYear',
|
||||
'',
|
||||
'user-1',
|
||||
'[email protected]',
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
graph_tree_router._require_allowed_neo4j_db(
|
||||
'classroomcopilot',
|
||||
'School',
|
||||
'school',
|
||||
'user-1',
|
||||
'[email protected]',
|
||||
)
|
||||
assert exc.value.status_code == 403
|
||||
@@ -0,0 +1,69 @@
|
||||
import jwt
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from modules.auth.supabase_bearer import verify_supabase_token_dep
|
||||
from routers.tlsync_token import TLSYNC_TOKEN_AUDIENCE, create_tlsync_token, router
|
||||
|
||||
|
||||
def test_create_tlsync_token_is_short_lived_and_signed(monkeypatch):
|
||||
monkeypatch.setenv("TLSYNC_SECRET", "test-tlsync-secret-with-at-least-32-bytes")
|
||||
monkeypatch.setenv("TLSYNC_TOKEN_TTL_SECONDS", "120")
|
||||
|
||||
response = create_tlsync_token({"sub": "user-123"})
|
||||
|
||||
assert response["token_type"] == "Bearer"
|
||||
assert response["expires_in"] == 120
|
||||
assert response["token"]
|
||||
|
||||
payload = jwt.decode(
|
||||
response["token"],
|
||||
"test-tlsync-secret-with-at-least-32-bytes",
|
||||
algorithms=["HS256"],
|
||||
audience=TLSYNC_TOKEN_AUDIENCE,
|
||||
)
|
||||
assert payload["sub"] == "user-123"
|
||||
assert payload["exp"] == response["expires_at"]
|
||||
assert payload["exp"] - payload["iat"] == 120
|
||||
assert payload["jti"]
|
||||
|
||||
|
||||
def test_create_tlsync_token_requires_tlsync_secret(monkeypatch):
|
||||
monkeypatch.delenv("TLSYNC_SECRET", raising=False)
|
||||
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
create_tlsync_token({"sub": "user-123"})
|
||||
|
||||
assert excinfo.value.status_code == 503
|
||||
|
||||
|
||||
def test_create_tlsync_token_requires_authenticated_subject(monkeypatch):
|
||||
monkeypatch.setenv("TLSYNC_SECRET", "test-tlsync-secret-with-at-least-32-bytes")
|
||||
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
create_tlsync_token({})
|
||||
|
||||
assert excinfo.value.status_code == 401
|
||||
|
||||
|
||||
def test_tlsync_token_route_uses_authenticated_user_claims(monkeypatch):
|
||||
monkeypatch.setenv("TLSYNC_SECRET", "test-tlsync-secret-with-at-least-32-bytes")
|
||||
monkeypatch.setenv("TLSYNC_TOKEN_TTL_SECONDS", "60")
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/tlsync")
|
||||
app.dependency_overrides[verify_supabase_token_dep] = lambda: {"sub": "route-user-123"}
|
||||
|
||||
response = TestClient(app).get("/api/tlsync/token", headers={"Authorization": "Bearer supabase-jwt"})
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
payload = jwt.decode(
|
||||
body["token"],
|
||||
"test-tlsync-secret-with-at-least-32-bytes",
|
||||
algorithms=["HS256"],
|
||||
audience=TLSYNC_TOKEN_AUDIENCE,
|
||||
)
|
||||
assert payload["sub"] == "route-user-123"
|
||||
assert body["expires_in"] == 60
|
||||
Reference in New Issue
Block a user