Files
TapTrack-Hub/backend/app/routers/dashboard.py
kevin-asprec 9ef8f1a421 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
2026-03-16 14:28:52 +08:00

176 lines
6.6 KiB
Python

"""Super admin dashboard summary."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, desc
from datetime import date, timedelta
from typing import Optional
from app.auth.dependencies import require_super_admin
from app.database import get_db
from app.models.user import HubUser
from app.models.school import School, SchoolStatus
from app.models.license import License, LicenseStatus
from app.models.sms import SmsJob, SmsJobStatus
from app.models.billing import Invoice, InvoiceStatus
from app.models.ticket import SupportTicket, TicketStatus
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
@router.get("/summary")
async def get_summary(
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
total_schools = (await db.execute(select(func.count()).select_from(School))).scalar_one()
active_schools = (await db.execute(select(func.count()).where(School.status == SchoolStatus.active))).scalar_one()
suspended_schools = (await db.execute(select(func.count()).where(School.status == SchoolStatus.suspended))).scalar_one()
expiring_soon = (await db.execute(
select(func.count()).where(
and_(License.expires_at != None, License.expires_at <= date.today() + timedelta(days=30),
License.status == LicenseStatus.active)
)
)).scalar_one()
open_tickets = (await db.execute(
select(func.count()).where(SupportTicket.status.in_([TicketStatus.open, TicketStatus.in_progress]))
)).scalar_one()
pending_invoices = (await db.execute(
select(func.count()).where(Invoice.status.in_([InvoiceStatus.sent, InvoiceStatus.overdue]))
)).scalar_one()
sms_today = (await db.execute(
select(func.count()).where(
and_(func.date(SmsJob.created_at) == date.today(), SmsJob.status == SmsJobStatus.sent)
)
)).scalar_one()
sms_pending = (await db.execute(
select(func.count()).where(SmsJob.status == SmsJobStatus.pending)
)).scalar_one()
return {
"schools": {"total": total_schools, "active": active_schools, "suspended": suspended_schools},
"licenses": {"expiring_soon": expiring_soon},
"tickets": {"open": open_tickets},
"invoices": {"pending": pending_invoices},
"sms": {"sent_today": sms_today, "pending": sms_pending},
}
@router.get("/school-health")
async def get_school_health(
sort_by: Optional[str] = Query("name", regex="^(name|status|sms_credits|license_expires_at|license_last_seen|created_at)$"),
sort_dir: Optional[str] = Query("asc", regex="^(asc|desc)$"),
status: Optional[SchoolStatus] = Query(None),
search: Optional[str] = Query(None),
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
"""
School health table for the super admin dashboard.
Returns all schools with their license expiry, last seen, and credit status.
"""
stmt = select(School)
if status:
stmt = stmt.where(School.status == status)
if search:
stmt = stmt.where(School.name.ilike(f"%{search}%"))
# Apply sort
sort_col = {
"name": School.name,
"status": School.status,
"sms_credits": School.sms_credits,
"created_at": School.created_at,
}.get(sort_by, School.name)
stmt = stmt.order_by(desc(sort_col) if sort_dir == "desc" else sort_col)
schools = (await db.execute(stmt)).scalars().all()
# Fetch licenses in bulk
all_lics = {
lic.school_id: lic
for lic in (await db.execute(select(License))).scalars().all()
}
items = []
for s in schools:
lic = all_lics.get(s.id)
# Days until license expires
days_left = None
if lic and lic.expires_at:
days_left = (lic.expires_at - date.today()).days
items.append({
"id": s.id,
"name": s.name,
"slug": s.slug,
"status": s.status.value,
"tier": s.tier.value,
"sms_credits": float(s.sms_credits),
"sms_credit_low": float(s.sms_credits) < s.sms_credit_low_threshold,
"license_status": lic.status.value if lic else None,
"license_expires_at": lic.expires_at.isoformat() if lic and lic.expires_at else None,
"license_days_left": days_left,
"license_expiring_soon": days_left is not None and 0 < days_left <= 30,
"license_last_seen": lic.last_validated_at.isoformat() if lic and lic.last_validated_at else None,
"created_at": s.created_at.isoformat(),
})
return {"items": items, "total": len(items)}
@router.get("/revenue")
async def get_revenue_snapshot(
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
"""
Revenue snapshot for the super admin dashboard.
Returns: MRR (from active subscriptions), outstanding invoices total, overdue total.
"""
from app.models.billing import SchoolSubscription
# MRR: sum of monthly_fee from active subscriptions
mrr_result = (await db.execute(
select(func.sum(SchoolSubscription.monthly_fee)).where(SchoolSubscription.is_active == True)
)).scalar_one()
mrr = float(mrr_result or 0)
# Outstanding: all sent invoices total
outstanding_result = (await db.execute(
select(func.sum(Invoice.total_amount)).where(Invoice.status == InvoiceStatus.sent)
)).scalar_one()
outstanding = float(outstanding_result or 0)
# Overdue: all overdue invoices total
overdue_result = (await db.execute(
select(func.sum(Invoice.total_amount)).where(Invoice.status == InvoiceStatus.overdue)
)).scalar_one()
overdue = float(overdue_result or 0)
# Paid this month
paid_result = (await db.execute(
select(func.sum(Invoice.total_amount)).where(
and_(
Invoice.status == InvoiceStatus.paid,
func.date_trunc("month", Invoice.paid_at) == func.date_trunc("month", func.current_date())
)
)
)).scalar_one()
paid_this_month = float(paid_result or 0)
# Invoice counts
overdue_count = (await db.execute(
select(func.count()).where(Invoice.status == InvoiceStatus.overdue)
)).scalar_one()
outstanding_count = (await db.execute(
select(func.count()).where(Invoice.status == InvoiceStatus.sent)
)).scalar_one()
return {
"mrr": mrr,
"outstanding": outstanding,
"outstanding_count": outstanding_count,
"overdue": overdue,
"overdue_count": overdue_count,
"paid_this_month": paid_this_month,
}