Files
TapTrack-Hub/backend/app/routers/licenses.py
kevin-asprec b435847a39 feat(phase-2): school registry + license management UI
Plan 2-01: School create/edit modal + license editor
- SchoolsPage: full create/edit modal (name, city, address, tier, contact,
  billing email, SMS sender name, student limit, status, notes); debounced
  search + status filter; Edit button per row opens pre-filled modal
- SchoolDetailPage: Activate / Suspend / Edit quick-action buttons in header;
  license panel with key copy, expiry, last seen (time-ago); license editor
  (expiry date, max students, tier) calls PUT /api/licenses/{id}; Revoke
  button calls POST /api/licenses/{id}/revoke
- Backend: GET /api/licenses/school/{school_id} — fetch license by school
- api.ts: getSchoolLicense added

Plan 2-02: Subscription setup + SMS credit top-up + ledger
- SchoolDetailPage: subscription panel (monthly fee, SMS cost per msg,
  billing cycle, next billing date) upserts via billing API
- SMS Credits panel: color-coded credit meter (green/amber/red based on
  balance), preset +100/+500/+1000 buttons, custom amount + note field,
  recent 5 ledger entries, link to full SMS page
- All operations toast on success/error

PAUL: Phase 2 marked complete, STATE + ROADMAP updated
2026-03-16 08:09:59 +08:00

137 lines
5.4 KiB
Python

"""License management endpoints."""
from datetime import date, datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from app.auth.dependencies import require_super_admin
from app.database import get_db
from app.models.user import HubUser
from app.models.license import License, LicenseStatus
from app.models.school import School, SchoolStatus
router = APIRouter(prefix="/api/licenses", tags=["licenses"])
class LicenseUpdate(BaseModel):
status: Optional[LicenseStatus] = None
expires_at: Optional[date] = None
max_students: Optional[int] = None
notes: Optional[str] = None
@router.get("")
async def list_licenses(
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(License))
return [
{
"id": l.id, "school_id": l.school_id, "key": l.key,
"status": l.status.value, "tier": l.tier,
"issued_at": l.issued_at.isoformat(),
"expires_at": l.expires_at.isoformat() if l.expires_at else None,
"last_validated_at": l.last_validated_at.isoformat() if l.last_validated_at else None,
"last_seen_ip": l.last_seen_ip,
"max_students": l.max_students,
}
for l in result.scalars().all()
]
@router.put("/{license_id}")
async def update_license(
license_id: str,
body: LicenseUpdate,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
lic = (await db.execute(select(License).where(License.id == license_id))).scalar_one_or_none()
if not lic:
raise HTTPException(404, "License not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(lic, field, value)
await db.commit()
return {"id": lic.id, "status": lic.status.value, "expires_at": lic.expires_at.isoformat() if lic.expires_at else None}
@router.post("/{license_id}/revoke")
async def revoke_license(
license_id: str,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
lic = (await db.execute(select(License).where(License.id == license_id))).scalar_one_or_none()
if not lic:
raise HTTPException(404, "License not found")
lic.status = LicenseStatus.revoked
# Also suspend the school
school = (await db.execute(select(School).where(School.id == lic.school_id))).scalar_one_or_none()
if school:
school.status = SchoolStatus.suspended
await db.commit()
return {"message": "License revoked"}
@router.post("/validate")
async def validate_license(
request: Request,
db: AsyncSession = Depends(get_db),
):
"""Called by on-prem TapTrack to validate their license key. No auth required — uses key."""
body = await request.json()
key: str = body.get("key", "")
if not key:
raise HTTPException(400, "License key required")
lic = (await db.execute(select(License).where(License.key == key))).scalar_one_or_none()
if not lic:
return {"valid": False, "reason": "Key not found"}
if lic.status == LicenseStatus.revoked:
return {"valid": False, "reason": "License revoked"}
if lic.expires_at and lic.expires_at < date.today():
lic.status = LicenseStatus.expired
await db.commit()
return {"valid": False, "reason": "License expired", "expired_at": lic.expires_at.isoformat()}
# Update validation metadata
lic.last_validated_at = datetime.now(timezone.utc)
lic.last_seen_ip = request.client.host if request.client else None
school = (await db.execute(select(School).where(School.id == lic.school_id))).scalar_one_or_none()
await db.commit()
return {
"valid": True,
"school_id": lic.school_id,
"school_name": school.name if school else None,
"tier": lic.tier,
"max_students": lic.max_students,
"expires_at": lic.expires_at.isoformat() if lic.expires_at else None,
"sms_sender_name": school.sms_sender_name if school else "SCHOOL",
"sms_credits": float(school.sms_credits) if school else 0.0,
"features": _tier_features(lic.tier),
}
@router.get("/school/{school_id}")
async def get_school_license(
school_id: str,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
"""Get the license for a specific school."""
lic = (await db.execute(select(License).where(License.school_id == school_id))).scalar_one_or_none()
if not lic:
raise HTTPException(404, "No license found for this school")
return {
"id": lic.id, "school_id": lic.school_id, "key": lic.key,
"status": lic.status.value, "tier": lic.tier,
"issued_at": lic.issued_at.isoformat(),
"expires_at": lic.expires_at.isoformat() if lic.expires_at else None,
"last_validated_at": lic.last_validated_at.isoformat() if lic.last_validated_at else None,
"last_seen_ip": lic.last_seen_ip, "max_students": lic.max_students,
}
def _tier_features(tier: str) -> dict:
base = {"sms": True, "reports": True, "websocket": True, "multi_terminal": True}
if tier == "premium":
base.update({"api_keys": True, "webhooks": True, "bulk_enrollment": True})
elif tier == "basic":
base.update({"multi_terminal": False, "api_keys": False, "webhooks": False})
return base