Phase 10 — Support Ticket System:
- tickets.py router: SLA status (on_track/at_risk/breached/responded), email
notifications on create+reply via background threads, school_name in list,
priority filter, bulk-close endpoint
- tasks/tickets.py: escalate_stale Celery task (48h→high, 72h no reply→urgent)
- worker.py: escalate_stale scheduled every hour
- templates/email/ticket_notification.html: HTML ticket notification email
- TicketsPage.vue: status tabs, SLA badge, priority badge, school name column,
checkbox bulk-close, pagination
- TicketDetailPage.vue: inline priority/status/assignee selectors, SLA timer,
internal note lock icon, closed-ticket guard
Phase 11 — Monthly Report Generation:
- models/report.py: MonthlyReport + SchoolMonthlyStats ORM models
- tasks/reports.py: send_monthly_reports enhanced with SMS stats, attendance
data, invoice summary, stores MonthlyReport record per school per month
Phase 12 — On-Prem Monthly Report Pull:
- tasks/reports.py: pull_monthly_stats task — httpx GET to each school's
hub_base_url, upserts SchoolMonthlyStats; runs 1st at 5am
- worker.py: pull_monthly_stats scheduled 1st at 5am
Phase 13 — Feature Flags + Suspension:
- models/school.py: hub_base_url, feature_overrides (JSON), onboarding_completed_at
- routers/schools.py: PUT /{id}/feature-overrides endpoint
- routers/sync.py: _tier_features() merges school.feature_overrides into poll config
Phase 14 — Onboarding Wizard + Welcome Email:
- tasks/onboarding.py: send_welcome_email Celery task with license key
- routers/schools.py: auto-trigger welcome email on POST /schools,
POST /{id}/activate (status→active + onboarding_completed_at),
POST /{id}/resend-welcome
Phase 15 — UX Polish + Ops Tools:
- routers/search.py: GET /api/search?q= (schools + invoices + tickets, 5 each)
- routers/audit.py: GET /api/audit-logs (paginated, filterable)
- AppLayout.vue: global search bar with debounced dropdown, result navigation
- AuditLogsPage.vue: new page with filter + pagination
- AppSidebar.vue: Audit Logs nav item added
- router/index.ts: /audit-logs route
- api.ts: globalSearch, getAuditLogs, activateSchool, resendWelcomeEmail,
updateFeatureOverrides, bulkCloseTickets
Deployment:
- docker-compose.yml: x-backend-env anchor (DRY), PDF_DIR env var,
seed service (one-shot python seed.py on first boot)
- migrations/003_phases11_15.py: monthly_reports, school_monthly_stats tables
+ schools hub_base_url/feature_overrides/onboarding_completed_at columns
224 lines
8.8 KiB
Python
224 lines
8.8 KiB
Python
"""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}
|