"""School registry endpoints — super admin only.""" import json import uuid from datetime import datetime, timezone from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, EmailStr from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, desc from slugify import slugify from app.auth.dependencies import require_super_admin, get_current_user from app.database import get_db from app.models.user import HubUser from app.models.school import School, SchoolStatus, LicenseTier from app.models.license import License, LicenseStatus router = APIRouter(prefix="/api/schools", tags=["schools"]) class SchoolCreate(BaseModel): name: str address: Optional[str] = None city: Optional[str] = None contact_name: Optional[str] = None contact_email: Optional[EmailStr] = None contact_phone: Optional[str] = None billing_email: Optional[EmailStr] = None tier: LicenseTier = LicenseTier.standard student_limit: int = 500 sms_sender_name: str = "SCHOOL" notes: Optional[str] = None class SchoolUpdate(BaseModel): name: Optional[str] = None address: Optional[str] = None city: Optional[str] = None contact_name: Optional[str] = None contact_email: Optional[EmailStr] = None contact_phone: Optional[str] = None billing_email: Optional[EmailStr] = None tier: Optional[LicenseTier] = None student_limit: Optional[int] = None sms_sender_name: Optional[str] = None status: Optional[SchoolStatus] = None hub_base_url: Optional[str] = None notes: Optional[str] = None class FeatureOverridesBody(BaseModel): overrides: dict def _school_out(s: School, license: License | None = None) -> dict: feat = {} if s.feature_overrides: try: feat = json.loads(s.feature_overrides) if isinstance(s.feature_overrides, str) else {} except Exception: feat = {} return { "id": s.id, "name": s.name, "slug": s.slug, "address": s.address, "city": s.city, "contact_name": s.contact_name, "contact_email": s.contact_email, "contact_phone": s.contact_phone, "billing_email": s.billing_email, "status": s.status.value, "tier": s.tier.value, "student_limit": s.student_limit, "sms_sender_name": s.sms_sender_name, "sms_credits": float(s.sms_credits), "sms_credit_low_threshold": s.sms_credit_low_threshold, "hub_base_url": s.hub_base_url, "feature_overrides": feat, "onboarding_completed_at": s.onboarding_completed_at.isoformat() if s.onboarding_completed_at else None, "created_at": s.created_at.isoformat(), "notes": s.notes, "license_key": license.key if license else None, "license_status": license.status.value if license else None, "license_expires_at": license.expires_at.isoformat() if license and license.expires_at else None, "license_last_seen": license.last_validated_at.isoformat() if license and license.last_validated_at else None, } @router.get("") async def list_schools( page: int = Query(1, ge=1), per_page: int = Query(25, ge=1, le=100), search: Optional[str] = Query(None), status: Optional[SchoolStatus] = Query(None), _admin: HubUser = Depends(require_super_admin), db: AsyncSession = Depends(get_db), ): stmt = select(School).order_by(desc(School.created_at)) if search: stmt = stmt.where(School.name.ilike(f"%{search}%")) if status: stmt = stmt.where(School.status == status) total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one() schools = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all() items = [] for s in schools: lic = (await db.execute(select(License).where(License.school_id == s.id))).scalar_one_or_none() items.append(_school_out(s, lic)) return {"items": items, "total": total, "page": page, "per_page": per_page} @router.post("", status_code=201) async def create_school( body: SchoolCreate, _admin: HubUser = Depends(require_super_admin), db: AsyncSession = Depends(get_db), ): slug = slugify(body.name) existing = (await db.execute(select(School).where(School.slug == slug))).scalar_one_or_none() if existing: slug = f"{slug}-{uuid.uuid4().hex[:6]}" school = School( name=body.name, slug=slug, address=body.address, city=body.city, contact_name=body.contact_name, contact_email=body.contact_email, contact_phone=body.contact_phone, billing_email=body.billing_email, tier=body.tier, student_limit=body.student_limit, sms_sender_name=body.sms_sender_name[:11], notes=body.notes, status=SchoolStatus.pending, ) db.add(school) await db.flush() lic = License(school_id=school.id, tier=body.tier.value, max_students=body.student_limit) db.add(lic) await db.commit() await db.refresh(school) try: from app.tasks.onboarding import send_welcome_email send_welcome_email.delay(school.id) except Exception: pass return _school_out(school, lic) @router.get("/{school_id}") async def get_school( school_id: str, _admin: HubUser = Depends(require_super_admin), db: AsyncSession = Depends(get_db), ): school = (await db.execute(select(School).where(School.id == school_id))).scalar_one_or_none() if not school: raise HTTPException(404, "School not found") lic = (await db.execute(select(License).where(License.school_id == school_id))).scalar_one_or_none() return _school_out(school, lic) @router.put("/{school_id}") async def update_school( school_id: str, body: SchoolUpdate, _admin: HubUser = Depends(require_super_admin), db: AsyncSession = Depends(get_db), ): school = (await db.execute(select(School).where(School.id == school_id))).scalar_one_or_none() if not school: raise HTTPException(404, "School not found") for field, value in body.model_dump(exclude_none=True).items(): if field == "sms_sender_name": value = value[:11] setattr(school, field, value) await db.commit() lic = (await db.execute(select(License).where(License.school_id == school_id))).scalar_one_or_none() return _school_out(school, lic) @router.put("/{school_id}/feature-overrides") async def update_feature_overrides( school_id: str, body: FeatureOverridesBody, _admin: HubUser = Depends(require_super_admin), db: AsyncSession = Depends(get_db), ): school = (await db.execute(select(School).where(School.id == school_id))).scalar_one_or_none() if not school: raise HTTPException(404, "School not found") school.feature_overrides = json.dumps(body.overrides) await db.commit() return {"feature_overrides": body.overrides} @router.post("/{school_id}/activate") async def activate_school( school_id: str, _admin: HubUser = Depends(require_super_admin), db: AsyncSession = Depends(get_db), ): school = (await db.execute(select(School).where(School.id == school_id))).scalar_one_or_none() if not school: raise HTTPException(404, "School not found") lic = (await db.execute(select(License).where(License.school_id == school_id))).scalar_one_or_none() if not lic: raise HTTPException(400, "No license issued — cannot activate") school.status = SchoolStatus.active school.onboarding_completed_at = datetime.now(timezone.utc) await db.commit() return _school_out(school, lic) @router.post("/{school_id}/resend-welcome") async def resend_welcome( school_id: str, _admin: HubUser = Depends(require_super_admin), db: AsyncSession = Depends(get_db), ): school = (await db.execute(select(School).where(School.id == school_id))).scalar_one_or_none() if not school: raise HTTPException(404, "School not found") try: from app.tasks.onboarding import send_welcome_email send_welcome_email.delay(school_id) except Exception: raise HTTPException(500, "Failed to queue welcome email") return {"message": "Welcome email queued"} @router.post("/{school_id}/credits") async def add_sms_credits( school_id: str, amount: float, description: Optional[str] = None, _admin: HubUser = Depends(require_super_admin), db: AsyncSession = Depends(get_db), ): from app.models.sms import SmsCreditLedger, SmsCreditTx school = (await db.execute(select(School).where(School.id == school_id))).scalar_one_or_none() if not school: raise HTTPException(404, "School not found") school.sms_credits = float(school.sms_credits) + amount db.add(SmsCreditLedger( school_id=school_id, tx_type=SmsCreditTx.topup, amount=amount, balance_after=float(school.sms_credits), description=description or f"Manual top-up of {amount} credits", )) await db.commit() return {"sms_credits": float(school.sms_credits), "added": amount}