Backend (school_portal.py): - GET /portal/sms-stats: 30/7/90-day chart, delivery rate, credit burn this month - POST /portal/credits/request: auto-creates billing ticket for credit top-up - GET /portal/overview: extended with days_left, sms_credit_low flag, 30-day SMS chart, full school details PortalOverviewPage: suspension/expired banner, low-credit warning, credit meter with progress bar, license countdown with expiry badge, 30-day SMS sparkline PortalBillingPage: subscription plan panel (fee, SMS cost, cycle, next billing), invoice table with subscription/SMS breakdown, credit top-up request form PortalSmsPage: 30/7/90d period picker, 4-stat KPI row (sent, failed, delivery rate, credit burn), daily volume chart, credit snapshot with top-up link, paginated SMS log with status filter PortalTicketsPage: status filter tabs (all/open/in-progress/resolved/closed), pagination, improved empty states PortalProfilePage: school details panel (name, city, contact, tier, status) alongside existing change-password form
239 lines
8.2 KiB
Python
239 lines
8.2 KiB
Python
"""School admin portal — school-scoped read endpoints."""
|
|
import uuid
|
|
from datetime import date, datetime, timedelta, timezone
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, func, desc, and_
|
|
|
|
from app.auth.dependencies import require_school_admin, get_current_user
|
|
from app.database import get_db
|
|
from app.models.user import HubUser, UserRole
|
|
from app.models.school import School
|
|
from app.models.license import License
|
|
from app.models.billing import Invoice, SchoolSubscription
|
|
from app.models.sms import SmsJob, SmsJobStatus, SmsCreditLedger, SmsCreditTx
|
|
from app.models.ticket import SupportTicket, TicketCategory
|
|
|
|
router = APIRouter(prefix="/api/portal", tags=["school-portal"])
|
|
|
|
|
|
async def _get_school(current_user: HubUser, db: AsyncSession) -> School:
|
|
if not current_user.school_id:
|
|
raise HTTPException(400, "No school linked to your account")
|
|
school = (await db.execute(
|
|
select(School).where(School.id == current_user.school_id)
|
|
)).scalar_one_or_none()
|
|
if not school:
|
|
raise HTTPException(404, "School not found")
|
|
return school
|
|
|
|
|
|
@router.get("/overview")
|
|
async def portal_overview(
|
|
current_user: HubUser = Depends(require_school_admin),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
school = await _get_school(current_user, db)
|
|
lic = (await db.execute(
|
|
select(License).where(License.school_id == school.id)
|
|
)).scalar_one_or_none()
|
|
|
|
pending_inv = (await db.execute(
|
|
select(func.count()).where(
|
|
and_(Invoice.school_id == school.id, Invoice.status.in_(["sent", "overdue"]))
|
|
)
|
|
)).scalar_one()
|
|
|
|
sms_this_month = (await db.execute(
|
|
select(func.count()).where(
|
|
and_(
|
|
SmsJob.school_id == school.id,
|
|
SmsJob.status == SmsJobStatus.sent,
|
|
func.date_trunc("month", SmsJob.sent_at) == func.date_trunc("month", func.current_date()),
|
|
)
|
|
)
|
|
)).scalar_one()
|
|
|
|
open_tickets = (await db.execute(
|
|
select(func.count()).where(
|
|
and_(SupportTicket.school_id == school.id, SupportTicket.status.in_(["open", "in_progress"]))
|
|
)
|
|
)).scalar_one()
|
|
|
|
# Days until license expires
|
|
license_days_left = None
|
|
if lic and lic.expires_at:
|
|
license_days_left = (lic.expires_at - date.today()).days
|
|
|
|
# 30-day SMS mini-chart (sent only, for the overview sparkline)
|
|
today = date.today()
|
|
chart = []
|
|
for i in range(29, -1, -1):
|
|
d = today - timedelta(days=i)
|
|
sent_count = (await db.execute(
|
|
select(func.count()).where(
|
|
and_(
|
|
SmsJob.school_id == school.id,
|
|
SmsJob.status == SmsJobStatus.sent,
|
|
func.date(SmsJob.sent_at) == d,
|
|
)
|
|
)
|
|
)).scalar_one()
|
|
chart.append({"date": d.isoformat(), "sent": sent_count})
|
|
|
|
return {
|
|
"school": {
|
|
"id": school.id,
|
|
"name": school.name,
|
|
"status": school.status.value,
|
|
"tier": school.tier.value,
|
|
"city": school.city,
|
|
"contact_name": school.contact_name,
|
|
"contact_email": school.contact_email,
|
|
"contact_phone": school.contact_phone,
|
|
},
|
|
"license": {
|
|
"key": lic.key if lic else None,
|
|
"status": lic.status.value if lic else None,
|
|
"expires_at": lic.expires_at.isoformat() if lic and lic.expires_at else None,
|
|
"days_left": license_days_left,
|
|
"last_seen": lic.last_validated_at.isoformat() if lic and lic.last_validated_at else None,
|
|
},
|
|
"sms_credits": float(school.sms_credits),
|
|
"sms_credit_low_threshold": school.sms_credit_low_threshold,
|
|
"sms_credit_low": float(school.sms_credits) <= school.sms_credit_low_threshold,
|
|
"sms_this_month": sms_this_month,
|
|
"sms_chart": chart,
|
|
"pending_invoices": pending_inv,
|
|
"open_tickets": open_tickets,
|
|
}
|
|
|
|
|
|
@router.get("/sms-stats")
|
|
async def portal_sms_stats(
|
|
days: int = Query(30, ge=7, le=90),
|
|
current_user: HubUser = Depends(require_school_admin),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
SMS statistics for the school portal.
|
|
Returns daily chart, totals, delivery rate, and credit burn this month.
|
|
"""
|
|
school = await _get_school(current_user, db)
|
|
today = date.today()
|
|
|
|
# Build daily chart
|
|
chart = []
|
|
for i in range(days - 1, -1, -1):
|
|
d = today - timedelta(days=i)
|
|
sent_count = (await db.execute(
|
|
select(func.count()).where(
|
|
and_(SmsJob.school_id == school.id, SmsJob.status == SmsJobStatus.sent,
|
|
func.date(SmsJob.sent_at) == d)
|
|
)
|
|
)).scalar_one()
|
|
failed_count = (await db.execute(
|
|
select(func.count()).where(
|
|
and_(SmsJob.school_id == school.id, SmsJob.status == SmsJobStatus.failed,
|
|
func.date(SmsJob.created_at) == d)
|
|
)
|
|
)).scalar_one()
|
|
chart.append({"date": d.isoformat(), "sent": sent_count, "failed": failed_count})
|
|
|
|
# Period totals
|
|
period_start = today - timedelta(days=days - 1)
|
|
total = (await db.execute(
|
|
select(func.count()).where(
|
|
and_(SmsJob.school_id == school.id,
|
|
func.date(SmsJob.created_at) >= period_start)
|
|
)
|
|
)).scalar_one()
|
|
sent = (await db.execute(
|
|
select(func.count()).where(
|
|
and_(SmsJob.school_id == school.id, SmsJob.status == SmsJobStatus.sent,
|
|
func.date(SmsJob.created_at) >= period_start)
|
|
)
|
|
)).scalar_one()
|
|
failed = (await db.execute(
|
|
select(func.count()).where(
|
|
and_(SmsJob.school_id == school.id, SmsJob.status == SmsJobStatus.failed,
|
|
func.date(SmsJob.created_at) >= period_start)
|
|
)
|
|
)).scalar_one()
|
|
|
|
delivery_rate = round(sent / total * 100, 1) if total > 0 else 0.0
|
|
|
|
# Credit burn this month
|
|
burn_result = (await db.execute(
|
|
select(func.sum(SmsCreditLedger.amount)).where(
|
|
and_(
|
|
SmsCreditLedger.school_id == school.id,
|
|
SmsCreditLedger.tx_type == SmsCreditTx.deduct,
|
|
func.date_trunc("month", SmsCreditLedger.created_at) ==
|
|
func.date_trunc("month", func.current_date()),
|
|
)
|
|
)
|
|
)).scalar_one()
|
|
credit_burn_this_month = abs(float(burn_result or 0))
|
|
|
|
return {
|
|
"chart": chart,
|
|
"total": total,
|
|
"sent": sent,
|
|
"failed": failed,
|
|
"delivery_rate": delivery_rate,
|
|
"credit_burn_this_month": credit_burn_this_month,
|
|
"current_credits": float(school.sms_credits),
|
|
"period_days": days,
|
|
}
|
|
|
|
|
|
class CreditTopUpRequest(BaseModel):
|
|
amount_requested: int
|
|
notes: str = ""
|
|
|
|
|
|
@router.post("/credits/request", status_code=201)
|
|
async def request_credit_topup(
|
|
body: CreditTopUpRequest,
|
|
current_user: HubUser = Depends(require_school_admin),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
School admin requests a credit top-up.
|
|
Creates a billing support ticket automatically.
|
|
"""
|
|
school = await _get_school(current_user, db)
|
|
|
|
if body.amount_requested < 1:
|
|
raise HTTPException(400, "Amount must be at least 1")
|
|
|
|
count = (await db.execute(select(func.count()).select_from(SupportTicket))).scalar_one()
|
|
|
|
ticket_body = (
|
|
f"SMS Credit Top-Up Request\n\n"
|
|
f"School: {school.name}\n"
|
|
f"Requested amount: {body.amount_requested} credits\n"
|
|
f"Current balance: {float(school.sms_credits):.0f} credits\n"
|
|
)
|
|
if body.notes:
|
|
ticket_body += f"\nNotes: {body.notes}"
|
|
|
|
ticket = SupportTicket(
|
|
school_id=school.id,
|
|
submitted_by=current_user.id,
|
|
ticket_number=f"TKT-{date.today().year}-{count + 1:05d}",
|
|
subject=f"Credit Top-Up Request — {body.amount_requested} credits",
|
|
body=ticket_body,
|
|
category=TicketCategory.billing,
|
|
)
|
|
db.add(ticket)
|
|
await db.commit()
|
|
|
|
return {
|
|
"ticket_id": ticket.id,
|
|
"ticket_number": ticket.ticket_number,
|
|
"message": f"Top-up request for {body.amount_requested} credits submitted. We'll process it shortly.",
|
|
}
|