Full project scaffold for TapTrack Hub — cloud SaaS control plane for managing on-prem TapTrack school deployments. ## Infrastructure - Docker Compose: backend (gunicorn+uvicorn), Celery worker + beat, frontend (Vite build + nginx), PostgreSQL 15, Redis 7, nginx proxy - Dockerfile for backend and frontend, nginx reverse proxy config ## Backend (FastAPI + SQLAlchemy async + Celery) Database schema (10 tables): hub_users, schools, licenses, sms_jobs, sms_credit_ledger, invoices, invoice_line_items, school_subscriptions, support_tickets, ticket_replies, audit_logs, announcements Auth: JWT (python-jose) + bcrypt + role-based FastAPI dependencies (get_current_user, require_super_admin, require_school_admin) Routers (11): auth, schools, licenses, sms, billing, tickets, users, dashboard, school_portal, announcements, sync Celery tasks (6): sms.process_queue, billing.generate_monthly_invoices, billing.send_invoice_email, billing.check_overdue, license.check_expiry, reports.send_monthly_reports Services: SMTP email helper (smtplib + Jinja2) Seed script: creates super admin admin@taptrack.io ## Frontend (Vue 3 + Vite + Pinia + Tailwind CSS) Router: 14 routes across super admin + school portal layouts Stores: Pinia auth store with localStorage persistence API client: full axios client for all backend endpoints Layouts: AppLayout (super admin), PortalLayout (school), AuthLayout Components: AppSidebar, PortalSidebar, SidebarItem, KpiCard, StatusBadge, ToastStack Pages: Login, Dashboard, Schools, SchoolDetail, Licenses, SMS, Billing, Tickets, TicketDetail, Users, Announcements, 404 Portal pages: Overview, Billing, SMS Reports, Tickets, Profile ## PAUL Planning Files - .paul/ROADMAP.md: full 15-phase roadmap with detailed scope - .paul/STATE.md: current position, tech stack, architecture notes - .paul/phases/01-setup/01-PLAN.md: complete Phase 1 plan (done) - .paul/phases/02 through 15: README stubs for all future phases
182 lines
6.7 KiB
Python
182 lines
6.7 KiB
Python
"""School registry endpoints — super admin only."""
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Optional, Any
|
|
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
|
|
notes: Optional[str] = None
|
|
|
|
def _school_out(s: School, license: License | None = None) -> dict:
|
|
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,
|
|
"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_res = await db.execute(select(License).where(License.school_id == s.id))
|
|
lic = lic_res.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)
|
|
# Ensure slug uniqueness
|
|
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()
|
|
# Auto-create license
|
|
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)
|
|
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.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
|
|
ledger = 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",
|
|
)
|
|
db.add(ledger)
|
|
await db.commit()
|
|
return {"sms_credits": float(school.sms_credits), "added": amount}
|