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
176 lines
6.6 KiB
Python
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,
|
|
}
|