feat(phases-10-15): complete TapTrack Hub v1.0

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
This commit is contained in:
kevin-asprec
2026-03-16 14:28:52 +08:00
parent 1febb3cfa9
commit 9ef8f1a421
26 changed files with 1375 additions and 213 deletions

View File

@@ -1,7 +1,8 @@
"""School registry endpoints — super admin only."""
import json
import uuid
from datetime import datetime, timezone
from typing import Optional, Any
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, EmailStr
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,6 +17,7 @@ from app.models.license import License, LicenseStatus
router = APIRouter(prefix="/api/schools", tags=["schools"])
class SchoolCreate(BaseModel):
name: str
address: Optional[str] = None
@@ -29,6 +31,7 @@ class SchoolCreate(BaseModel):
sms_sender_name: str = "SCHOOL"
notes: Optional[str] = None
class SchoolUpdate(BaseModel):
name: Optional[str] = None
address: Optional[str] = None
@@ -41,41 +44,43 @@ class SchoolUpdate(BaseModel):
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),
"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,
"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),
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:
@@ -86,11 +91,11 @@ async def list_schools(
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()
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,
@@ -98,34 +103,31 @@ async def create_school(
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,
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)
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,
@@ -138,12 +140,11 @@ async def get_school(
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_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:
@@ -156,26 +157,67 @@ async def update_school(
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),
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,
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",
)
db.add(ledger)
))
await db.commit()
return {"sms_credits": float(school.sms_credits), "added": amount}