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
137 lines
5.4 KiB
Python
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
|