Full project scaffold for TapTrack Hub — cloud SaaS control plane for managing on-prem TapTrack school deployments. ## Infrastructure - Docker Compose: backend (gunicorn+uvicorn), Celery worker + beat, frontend (Vite build + nginx), PostgreSQL 15, Redis 7, nginx proxy - Dockerfile for backend and frontend, nginx reverse proxy config ## Backend (FastAPI + SQLAlchemy async + Celery) Database schema (10 tables): hub_users, schools, licenses, sms_jobs, sms_credit_ledger, invoices, invoice_line_items, school_subscriptions, support_tickets, ticket_replies, audit_logs, announcements Auth: JWT (python-jose) + bcrypt + role-based FastAPI dependencies (get_current_user, require_super_admin, require_school_admin) Routers (11): auth, schools, licenses, sms, billing, tickets, users, dashboard, school_portal, announcements, sync Celery tasks (6): sms.process_queue, billing.generate_monthly_invoices, billing.send_invoice_email, billing.check_overdue, license.check_expiry, reports.send_monthly_reports Services: SMTP email helper (smtplib + Jinja2) Seed script: creates super admin admin@taptrack.io ## Frontend (Vue 3 + Vite + Pinia + Tailwind CSS) Router: 14 routes across super admin + school portal layouts Stores: Pinia auth store with localStorage persistence API client: full axios client for all backend endpoints Layouts: AppLayout (super admin), PortalLayout (school), AuthLayout Components: AppSidebar, PortalSidebar, SidebarItem, KpiCard, StatusBadge, ToastStack Pages: Login, Dashboard, Schools, SchoolDetail, Licenses, SMS, Billing, Tickets, TicketDetail, Users, Announcements, 404 Portal pages: Overview, Billing, SMS Reports, Tickets, Profile ## PAUL Planning Files - .paul/ROADMAP.md: full 15-phase roadmap with detailed scope - .paul/STATE.md: current position, tech stack, architecture notes - .paul/phases/01-setup/01-PLAN.md: complete Phase 1 plan (done) - .paul/phases/02 through 15: README stubs for all future phases
117 lines
4.6 KiB
Python
117 lines
4.6 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),
|
|
}
|
|
|
|
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
|