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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user