"""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.", }