- timetable_builder_router: Supabase-primary slot write (POST /timetable/slots),
week_cycle support, GET /slots reads from Supabase, materialize-periods endpoint,
rebuild-neo4j endpoint, sync-lessons endpoint (Track B: TaughtLesson Neo4j nodes),
_sync_teacher_timetables_to_neo4j and _sync_taught_lessons_to_neo4j helpers
- classes_router: GET /{class_id} enriched with profiles + enrollment_requests,
GET /school/students for admin search, PATCH /enrollment-requests/{id} approve/reject
- taught_lessons_router: GET /student/lessons student week view with enrichment
- school_router: academic_periods sync, day-type management
- platform_admin_router + platform_admin: POST /admin/reset and /admin/seed endpoints
- invitations_router: teacher invite scaffolding
- reset_environment + seed_environment: idempotent dev environment scripts
- graph_tree_router: Supabase-first institute resolution
- provisioning_service: neo4j_private_db_name column support
- main.py + run/routers.py: register new routers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
604 lines
23 KiB
Python
604 lines
23 KiB
Python
"""
|
|
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,
|
|
}
|