feat(phase-1): TapTrack Hub initial scaffold
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
This commit is contained in:
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
54
backend/app/routers/announcements.py
Normal file
54
backend/app/routers/announcements.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Announcements — super admin creates, all users read."""
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc, and_
|
||||
|
||||
from app.auth.dependencies import require_super_admin, get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.user import HubUser
|
||||
from app.models.announcement import Announcement
|
||||
|
||||
router = APIRouter(prefix="/api/announcements", tags=["announcements"])
|
||||
|
||||
class AnnouncementCreate(BaseModel):
|
||||
title: str
|
||||
body: str
|
||||
expires_at: Optional[datetime] = None
|
||||
|
||||
@router.get("")
|
||||
async def list_announcements(
|
||||
current_user: HubUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(Announcement).where(
|
||||
and_(Announcement.is_active == True,
|
||||
(Announcement.expires_at == None) | (Announcement.expires_at > datetime.now(timezone.utc)))
|
||||
).order_by(desc(Announcement.created_at)).limit(10)
|
||||
items = (await db.execute(stmt)).scalars().all()
|
||||
return [{"id": a.id, "title": a.title, "body": a.body, "created_at": a.created_at.isoformat()} for a in items]
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_announcement(
|
||||
body: AnnouncementCreate,
|
||||
admin: HubUser = Depends(require_super_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
ann = Announcement(title=body.title, body=body.body, created_by=admin.id, expires_at=body.expires_at)
|
||||
db.add(ann)
|
||||
await db.commit()
|
||||
return {"id": ann.id, "title": ann.title, "created_at": ann.created_at.isoformat()}
|
||||
|
||||
@router.delete("/{ann_id}", status_code=204)
|
||||
async def delete_announcement(
|
||||
ann_id: str,
|
||||
_admin: HubUser = Depends(require_super_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
ann = (await db.execute(select(Announcement).where(Announcement.id == ann_id))).scalar_one_or_none()
|
||||
if not ann:
|
||||
raise HTTPException(404)
|
||||
ann.is_active = False
|
||||
await db.commit()
|
||||
73
backend/app/routers/auth.py
Normal file
73
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Authentication endpoints for TapTrack Hub."""
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.auth.password import verify_password, hash_password
|
||||
from app.auth.jwt import create_access_token
|
||||
from app.auth.dependencies import get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.user import HubUser, UserRole
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
role: str
|
||||
user_id: str
|
||||
full_name: str
|
||||
school_id: str | None
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(body: LoginRequest, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(HubUser).where(HubUser.email == body.email))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not verify_password(body.password, user.hashed_password):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=401, detail="Account is inactive")
|
||||
user.last_login_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
token = create_access_token({"sub": user.id, "role": user.role.value})
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
role=user.role.value,
|
||||
user_id=user.id,
|
||||
full_name=user.full_name,
|
||||
school_id=user.school_id,
|
||||
)
|
||||
|
||||
@router.get("/me")
|
||||
async def get_me(current_user: HubUser = Depends(get_current_user)):
|
||||
return {
|
||||
"id": current_user.id,
|
||||
"email": current_user.email,
|
||||
"full_name": current_user.full_name,
|
||||
"role": current_user.role.value,
|
||||
"school_id": current_user.school_id,
|
||||
"is_active": current_user.is_active,
|
||||
}
|
||||
|
||||
@router.put("/me/password", status_code=204)
|
||||
async def change_password(
|
||||
body: ChangePasswordRequest,
|
||||
current_user: HubUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if len(body.new_password) < 8:
|
||||
raise HTTPException(status_code=422, detail="Password must be at least 8 characters")
|
||||
if not verify_password(body.current_password, current_user.hashed_password):
|
||||
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||
current_user.hashed_password = hash_password(body.new_password)
|
||||
await db.commit()
|
||||
181
backend/app/routers/billing.py
Normal file
181
backend/app/routers/billing.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""Billing and invoice endpoints."""
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
|
||||
from app.auth.dependencies import require_super_admin, get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.user import HubUser, UserRole
|
||||
from app.models.billing import Invoice, InvoiceStatus, InvoiceLineItem, SchoolSubscription, BillingCycle
|
||||
|
||||
router = APIRouter(prefix="/api/billing", tags=["billing"])
|
||||
|
||||
class InvoiceCreate(BaseModel):
|
||||
school_id: str
|
||||
billing_period_start: date
|
||||
billing_period_end: date
|
||||
subscription_amount: float = 0.0
|
||||
sms_credit_amount: float = 0.0
|
||||
other_amount: float = 0.0
|
||||
due_date: Optional[date] = None
|
||||
notes: Optional[str] = None
|
||||
line_items: list[dict] = []
|
||||
|
||||
class InvoiceUpdate(BaseModel):
|
||||
status: Optional[InvoiceStatus] = None
|
||||
paid_at: Optional[datetime] = None
|
||||
payment_method: Optional[str] = None
|
||||
payment_reference: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
class SubscriptionUpsert(BaseModel):
|
||||
monthly_fee: float
|
||||
sms_cost_per_message: float = 1.0
|
||||
cycle: BillingCycle = BillingCycle.monthly
|
||||
next_billing_date: Optional[date] = None
|
||||
|
||||
def _inv_out(inv: Invoice) -> dict:
|
||||
return {
|
||||
"id": inv.id, "school_id": inv.school_id, "invoice_number": inv.invoice_number,
|
||||
"status": inv.status.value,
|
||||
"billing_period_start": inv.billing_period_start.isoformat(),
|
||||
"billing_period_end": inv.billing_period_end.isoformat(),
|
||||
"subscription_amount": float(inv.subscription_amount),
|
||||
"sms_credit_amount": float(inv.sms_credit_amount),
|
||||
"other_amount": float(inv.other_amount),
|
||||
"total_amount": float(inv.total_amount),
|
||||
"currency": inv.currency,
|
||||
"due_date": inv.due_date.isoformat() if inv.due_date else None,
|
||||
"paid_at": inv.paid_at.isoformat() if inv.paid_at else None,
|
||||
"payment_method": inv.payment_method,
|
||||
"payment_reference": inv.payment_reference,
|
||||
"email_sent_at": inv.email_sent_at.isoformat() if inv.email_sent_at else None,
|
||||
"created_at": inv.created_at.isoformat(),
|
||||
"notes": inv.notes,
|
||||
}
|
||||
|
||||
def _next_invoice_number(existing_count: int) -> str:
|
||||
from datetime import date
|
||||
return f"INV-{date.today().strftime('%Y%m')}-{existing_count + 1:04d}"
|
||||
|
||||
@router.get("/invoices")
|
||||
async def list_invoices(
|
||||
school_id: Optional[str] = Query(None),
|
||||
status: Optional[InvoiceStatus] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(25),
|
||||
current_user: HubUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(Invoice).order_by(desc(Invoice.created_at))
|
||||
if current_user.role != UserRole.super_admin:
|
||||
stmt = stmt.where(Invoice.school_id == current_user.school_id)
|
||||
elif school_id:
|
||||
stmt = stmt.where(Invoice.school_id == school_id)
|
||||
if status:
|
||||
stmt = stmt.where(Invoice.status == status)
|
||||
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
|
||||
invoices = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all()
|
||||
return {"items": [_inv_out(i) for i in invoices], "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
@router.post("/invoices", status_code=201)
|
||||
async def create_invoice(
|
||||
body: InvoiceCreate,
|
||||
_admin: HubUser = Depends(require_super_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
total = body.subscription_amount + body.sms_credit_amount + body.other_amount
|
||||
count = (await db.execute(select(func.count()).select_from(Invoice))).scalar_one()
|
||||
inv = Invoice(
|
||||
school_id=body.school_id,
|
||||
invoice_number=_next_invoice_number(count),
|
||||
billing_period_start=body.billing_period_start,
|
||||
billing_period_end=body.billing_period_end,
|
||||
subscription_amount=body.subscription_amount,
|
||||
sms_credit_amount=body.sms_credit_amount,
|
||||
other_amount=body.other_amount,
|
||||
total_amount=total,
|
||||
due_date=body.due_date,
|
||||
notes=body.notes,
|
||||
)
|
||||
db.add(inv)
|
||||
await db.flush()
|
||||
for item in body.line_items:
|
||||
db.add(InvoiceLineItem(
|
||||
invoice_id=inv.id,
|
||||
description=item.get("description", ""),
|
||||
quantity=item.get("quantity", 1),
|
||||
unit_price=item.get("unit_price", 0),
|
||||
amount=item.get("amount", 0),
|
||||
))
|
||||
await db.commit()
|
||||
return _inv_out(inv)
|
||||
|
||||
@router.put("/invoices/{invoice_id}")
|
||||
async def update_invoice(
|
||||
invoice_id: str,
|
||||
body: InvoiceUpdate,
|
||||
_admin: HubUser = Depends(require_super_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
inv = (await db.execute(select(Invoice).where(Invoice.id == invoice_id))).scalar_one_or_none()
|
||||
if not inv:
|
||||
raise HTTPException(404, "Invoice not found")
|
||||
for field, value in body.model_dump(exclude_none=True).items():
|
||||
setattr(inv, field, value)
|
||||
await db.commit()
|
||||
return _inv_out(inv)
|
||||
|
||||
@router.post("/invoices/{invoice_id}/send-email")
|
||||
async def send_invoice_email(
|
||||
invoice_id: str,
|
||||
_admin: HubUser = Depends(require_super_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from app.tasks.billing import send_invoice_email_task
|
||||
inv = (await db.execute(select(Invoice).where(Invoice.id == invoice_id))).scalar_one_or_none()
|
||||
if not inv:
|
||||
raise HTTPException(404, "Invoice not found")
|
||||
send_invoice_email_task.delay(invoice_id)
|
||||
return {"message": "Email queued"}
|
||||
|
||||
@router.get("/subscriptions/{school_id}")
|
||||
async def get_subscription(
|
||||
school_id: str,
|
||||
current_user: HubUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if current_user.role != UserRole.super_admin and current_user.school_id != school_id:
|
||||
raise HTTPException(403)
|
||||
sub = (await db.execute(select(SchoolSubscription).where(SchoolSubscription.school_id == school_id))).scalar_one_or_none()
|
||||
if not sub:
|
||||
raise HTTPException(404, "No subscription found")
|
||||
return {
|
||||
"id": sub.id, "school_id": sub.school_id, "cycle": sub.cycle.value,
|
||||
"monthly_fee": float(sub.monthly_fee), "sms_cost_per_message": float(sub.sms_cost_per_message),
|
||||
"next_billing_date": sub.next_billing_date.isoformat() if sub.next_billing_date else None,
|
||||
"is_active": sub.is_active,
|
||||
}
|
||||
|
||||
@router.put("/subscriptions/{school_id}")
|
||||
async def upsert_subscription(
|
||||
school_id: str,
|
||||
body: SubscriptionUpsert,
|
||||
_admin: HubUser = Depends(require_super_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sub = (await db.execute(select(SchoolSubscription).where(SchoolSubscription.school_id == school_id))).scalar_one_or_none()
|
||||
if sub:
|
||||
sub.monthly_fee = body.monthly_fee
|
||||
sub.sms_cost_per_message = body.sms_cost_per_message
|
||||
sub.cycle = body.cycle
|
||||
if body.next_billing_date:
|
||||
sub.next_billing_date = body.next_billing_date
|
||||
else:
|
||||
sub = SchoolSubscription(school_id=school_id, **body.model_dump())
|
||||
db.add(sub)
|
||||
await db.commit()
|
||||
return {"monthly_fee": float(sub.monthly_fee), "cycle": sub.cycle.value}
|
||||
53
backend/app/routers/dashboard.py
Normal file
53
backend/app/routers/dashboard.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Super admin dashboard summary."""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
from datetime import date, timedelta
|
||||
|
||||
from app.auth.dependencies import require_super_admin
|
||||
from app.database import get_db
|
||||
from app.models.user import HubUser
|
||||
from app.models.school import School, SchoolStatus
|
||||
from app.models.license import License, LicenseStatus
|
||||
from app.models.sms import SmsJob, SmsJobStatus
|
||||
from app.models.billing import Invoice, InvoiceStatus
|
||||
from app.models.ticket import SupportTicket, TicketStatus
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
||||
|
||||
@router.get("/summary")
|
||||
async def get_summary(
|
||||
_admin: HubUser = Depends(require_super_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
total_schools = (await db.execute(select(func.count()).select_from(School))).scalar_one()
|
||||
active_schools = (await db.execute(select(func.count()).where(School.status == SchoolStatus.active))).scalar_one()
|
||||
suspended_schools = (await db.execute(select(func.count()).where(School.status == SchoolStatus.suspended))).scalar_one()
|
||||
expiring_soon = (await db.execute(
|
||||
select(func.count()).where(
|
||||
and_(License.expires_at != None, License.expires_at <= date.today() + timedelta(days=30),
|
||||
License.status == LicenseStatus.active)
|
||||
)
|
||||
)).scalar_one()
|
||||
open_tickets = (await db.execute(
|
||||
select(func.count()).where(SupportTicket.status.in_([TicketStatus.open, TicketStatus.in_progress]))
|
||||
)).scalar_one()
|
||||
pending_invoices = (await db.execute(
|
||||
select(func.count()).where(Invoice.status.in_([InvoiceStatus.sent, InvoiceStatus.overdue]))
|
||||
)).scalar_one()
|
||||
sms_today = (await db.execute(
|
||||
select(func.count()).where(
|
||||
and_(func.date(SmsJob.created_at) == date.today(), SmsJob.status == SmsJobStatus.sent)
|
||||
)
|
||||
)).scalar_one()
|
||||
sms_pending = (await db.execute(
|
||||
select(func.count()).where(SmsJob.status == SmsJobStatus.pending)
|
||||
)).scalar_one()
|
||||
|
||||
return {
|
||||
"schools": {"total": total_schools, "active": active_schools, "suspended": suspended_schools},
|
||||
"licenses": {"expiring_soon": expiring_soon},
|
||||
"tickets": {"open": open_tickets},
|
||||
"invoices": {"pending": pending_invoices},
|
||||
"sms": {"sent_today": sms_today, "pending": sms_pending},
|
||||
}
|
||||
116
backend/app/routers/licenses.py
Normal file
116
backend/app/routers/licenses.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""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
|
||||
63
backend/app/routers/school_portal.py
Normal file
63
backend/app/routers/school_portal.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""School admin portal — school-scoped read endpoints."""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc, and_
|
||||
from datetime import date, timedelta
|
||||
|
||||
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
|
||||
from app.models.sms import SmsJob, SmsJobStatus
|
||||
from app.models.ticket import SupportTicket
|
||||
|
||||
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()
|
||||
|
||||
return {
|
||||
"school": {"id": school.id, "name": school.name, "status": school.status.value, "tier": school.tier.value},
|
||||
"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,
|
||||
"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_this_month": sms_this_month,
|
||||
"pending_invoices": pending_inv,
|
||||
"open_tickets": open_tickets,
|
||||
}
|
||||
181
backend/app/routers/schools.py
Normal file
181
backend/app/routers/schools.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""School registry endpoints — super admin only."""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
from slugify import slugify
|
||||
|
||||
from app.auth.dependencies import require_super_admin, get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.user import HubUser
|
||||
from app.models.school import School, SchoolStatus, LicenseTier
|
||||
from app.models.license import License, LicenseStatus
|
||||
|
||||
router = APIRouter(prefix="/api/schools", tags=["schools"])
|
||||
|
||||
class SchoolCreate(BaseModel):
|
||||
name: str
|
||||
address: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
contact_name: Optional[str] = None
|
||||
contact_email: Optional[EmailStr] = None
|
||||
contact_phone: Optional[str] = None
|
||||
billing_email: Optional[EmailStr] = None
|
||||
tier: LicenseTier = LicenseTier.standard
|
||||
student_limit: int = 500
|
||||
sms_sender_name: str = "SCHOOL"
|
||||
notes: Optional[str] = None
|
||||
|
||||
class SchoolUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
contact_name: Optional[str] = None
|
||||
contact_email: Optional[EmailStr] = None
|
||||
contact_phone: Optional[str] = None
|
||||
billing_email: Optional[EmailStr] = None
|
||||
tier: Optional[LicenseTier] = None
|
||||
student_limit: Optional[int] = None
|
||||
sms_sender_name: Optional[str] = None
|
||||
status: Optional[SchoolStatus] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
def _school_out(s: School, license: License | None = None) -> dict:
|
||||
return {
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"slug": s.slug,
|
||||
"address": s.address,
|
||||
"city": s.city,
|
||||
"contact_name": s.contact_name,
|
||||
"contact_email": s.contact_email,
|
||||
"contact_phone": s.contact_phone,
|
||||
"billing_email": s.billing_email,
|
||||
"status": s.status.value,
|
||||
"tier": s.tier.value,
|
||||
"student_limit": s.student_limit,
|
||||
"sms_sender_name": s.sms_sender_name,
|
||||
"sms_credits": float(s.sms_credits),
|
||||
"sms_credit_low_threshold": s.sms_credit_low_threshold,
|
||||
"created_at": s.created_at.isoformat(),
|
||||
"notes": s.notes,
|
||||
"license_key": license.key if license else None,
|
||||
"license_status": license.status.value if license else None,
|
||||
"license_expires_at": license.expires_at.isoformat() if license and license.expires_at else None,
|
||||
"license_last_seen": license.last_validated_at.isoformat() if license and license.last_validated_at else None,
|
||||
}
|
||||
|
||||
@router.get("")
|
||||
async def list_schools(
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(25, ge=1, le=100),
|
||||
search: Optional[str] = Query(None),
|
||||
status: Optional[SchoolStatus] = Query(None),
|
||||
_admin: HubUser = Depends(require_super_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(School).order_by(desc(School.created_at))
|
||||
if search:
|
||||
stmt = stmt.where(School.name.ilike(f"%{search}%"))
|
||||
if status:
|
||||
stmt = stmt.where(School.status == status)
|
||||
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
|
||||
schools = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all()
|
||||
items = []
|
||||
for s in schools:
|
||||
lic_res = await db.execute(select(License).where(License.school_id == s.id))
|
||||
lic = lic_res.scalar_one_or_none()
|
||||
items.append(_school_out(s, lic))
|
||||
return {"items": items, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_school(
|
||||
body: SchoolCreate,
|
||||
_admin: HubUser = Depends(require_super_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
slug = slugify(body.name)
|
||||
# Ensure slug uniqueness
|
||||
existing = (await db.execute(select(School).where(School.slug == slug))).scalar_one_or_none()
|
||||
if existing:
|
||||
slug = f"{slug}-{uuid.uuid4().hex[:6]}"
|
||||
school = School(
|
||||
name=body.name,
|
||||
slug=slug,
|
||||
address=body.address,
|
||||
city=body.city,
|
||||
contact_name=body.contact_name,
|
||||
contact_email=body.contact_email,
|
||||
contact_phone=body.contact_phone,
|
||||
billing_email=body.billing_email,
|
||||
tier=body.tier,
|
||||
student_limit=body.student_limit,
|
||||
sms_sender_name=body.sms_sender_name[:11],
|
||||
notes=body.notes,
|
||||
status=SchoolStatus.pending,
|
||||
)
|
||||
db.add(school)
|
||||
await db.flush()
|
||||
# Auto-create license
|
||||
lic = License(school_id=school.id, tier=body.tier.value, max_students=body.student_limit)
|
||||
db.add(lic)
|
||||
await db.commit()
|
||||
await db.refresh(school)
|
||||
return _school_out(school, lic)
|
||||
|
||||
@router.get("/{school_id}")
|
||||
async def get_school(
|
||||
school_id: str,
|
||||
_admin: HubUser = Depends(require_super_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
school = (await db.execute(select(School).where(School.id == school_id))).scalar_one_or_none()
|
||||
if not school:
|
||||
raise HTTPException(404, "School not found")
|
||||
lic = (await db.execute(select(License).where(License.school_id == school_id))).scalar_one_or_none()
|
||||
return _school_out(school, lic)
|
||||
|
||||
@router.put("/{school_id}")
|
||||
async def update_school(
|
||||
school_id: str,
|
||||
body: SchoolUpdate,
|
||||
_admin: HubUser = Depends(require_super_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
school = (await db.execute(select(School).where(School.id == school_id))).scalar_one_or_none()
|
||||
if not school:
|
||||
raise HTTPException(404, "School not found")
|
||||
for field, value in body.model_dump(exclude_none=True).items():
|
||||
if field == "sms_sender_name":
|
||||
value = value[:11]
|
||||
setattr(school, field, value)
|
||||
await db.commit()
|
||||
lic = (await db.execute(select(License).where(License.school_id == school_id))).scalar_one_or_none()
|
||||
return _school_out(school, lic)
|
||||
|
||||
@router.post("/{school_id}/credits")
|
||||
async def add_sms_credits(
|
||||
school_id: str,
|
||||
amount: float,
|
||||
description: Optional[str] = None,
|
||||
_admin: HubUser = Depends(require_super_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from app.models.sms import SmsCreditLedger, SmsCreditTx
|
||||
school = (await db.execute(select(School).where(School.id == school_id))).scalar_one_or_none()
|
||||
if not school:
|
||||
raise HTTPException(404, "School not found")
|
||||
school.sms_credits = float(school.sms_credits) + amount
|
||||
ledger = SmsCreditLedger(
|
||||
school_id=school_id,
|
||||
tx_type=SmsCreditTx.topup,
|
||||
amount=amount,
|
||||
balance_after=float(school.sms_credits),
|
||||
description=description or f"Manual top-up of {amount} credits",
|
||||
)
|
||||
db.add(ledger)
|
||||
await db.commit()
|
||||
return {"sms_credits": float(school.sms_credits), "added": amount}
|
||||
109
backend/app/routers/sms.py
Normal file
109
backend/app/routers/sms.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""SMS gateway endpoints."""
|
||||
from typing import Optional
|
||||
from datetime import datetime, 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_super_admin, require_school_admin, get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.user import HubUser, UserRole
|
||||
from app.models.sms import SmsJob, SmsJobStatus, SmsCreditLedger
|
||||
from app.models.school import School
|
||||
|
||||
router = APIRouter(prefix="/api/sms", tags=["sms"])
|
||||
|
||||
class SubmitSmsJob(BaseModel):
|
||||
"""Called by on-prem TapTrack to submit SMS jobs to Hub."""
|
||||
license_key: str
|
||||
jobs: list[dict] # [{ recipient_phone, message, trigger }]
|
||||
|
||||
class ManualSmsRequest(BaseModel):
|
||||
school_id: str
|
||||
recipient_phone: str
|
||||
message: str
|
||||
|
||||
@router.post("/submit", status_code=202)
|
||||
async def submit_sms_jobs(
|
||||
body: SubmitSmsJob,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""On-prem posts SMS jobs for Hub to process via Semaphore."""
|
||||
from app.models.license import License
|
||||
lic = (await db.execute(select(License).where(License.key == body.license_key))).scalar_one_or_none()
|
||||
if not lic:
|
||||
raise HTTPException(403, "Invalid license key")
|
||||
school = (await db.execute(select(School).where(School.id == lic.school_id))).scalar_one_or_none()
|
||||
if not school or float(school.sms_credits) <= 0:
|
||||
raise HTTPException(402, "Insufficient SMS credits")
|
||||
|
||||
created_ids = []
|
||||
for job_data in body.jobs:
|
||||
job = SmsJob(
|
||||
school_id=school.id,
|
||||
recipient_phone=job_data.get("recipient_phone", ""),
|
||||
message=job_data.get("message", ""),
|
||||
sender_name=school.sms_sender_name,
|
||||
trigger=job_data.get("trigger"),
|
||||
)
|
||||
db.add(job)
|
||||
created_ids.append(job.id)
|
||||
await db.commit()
|
||||
return {"queued": len(created_ids), "job_ids": created_ids}
|
||||
|
||||
@router.get("/jobs")
|
||||
async def list_sms_jobs(
|
||||
school_id: Optional[str] = Query(None),
|
||||
status: Optional[SmsJobStatus] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(50, ge=1, le=200),
|
||||
current_user: HubUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(SmsJob).order_by(desc(SmsJob.created_at))
|
||||
if current_user.role != UserRole.super_admin:
|
||||
stmt = stmt.where(SmsJob.school_id == current_user.school_id)
|
||||
elif school_id:
|
||||
stmt = stmt.where(SmsJob.school_id == school_id)
|
||||
if status:
|
||||
stmt = stmt.where(SmsJob.status == status)
|
||||
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
|
||||
jobs = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all()
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": j.id, "school_id": j.school_id, "recipient_phone": j.recipient_phone,
|
||||
"message": j.message[:60] + "..." if len(j.message) > 60 else j.message,
|
||||
"sender_name": j.sender_name, "status": j.status.value,
|
||||
"trigger": j.trigger, "created_at": j.created_at.isoformat(),
|
||||
"sent_at": j.sent_at.isoformat() if j.sent_at else None,
|
||||
"retry_count": j.retry_count, "error_message": j.error_message,
|
||||
}
|
||||
for j in jobs
|
||||
],
|
||||
"total": total, "page": page, "per_page": per_page,
|
||||
}
|
||||
|
||||
@router.get("/credits/{school_id}")
|
||||
async def get_credit_ledger(
|
||||
school_id: str,
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(50),
|
||||
current_user: HubUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if current_user.role != UserRole.super_admin and current_user.school_id != school_id:
|
||||
raise HTTPException(403)
|
||||
stmt = select(SmsCreditLedger).where(SmsCreditLedger.school_id == school_id).order_by(desc(SmsCreditLedger.created_at))
|
||||
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
|
||||
rows = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all()
|
||||
return {
|
||||
"items": [
|
||||
{"id": r.id, "tx_type": r.tx_type.value, "amount": float(r.amount),
|
||||
"balance_after": float(r.balance_after), "description": r.description,
|
||||
"created_at": r.created_at.isoformat()}
|
||||
for r in rows
|
||||
],
|
||||
"total": total,
|
||||
}
|
||||
77
backend/app/routers/sync.py
Normal file
77
backend/app/routers/sync.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""On-prem sync endpoint — polled by TapTrack every 30s to get SMS jobs and config."""
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_, update
|
||||
from fastapi import Depends
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.license import License, LicenseStatus
|
||||
from app.models.school import School
|
||||
from app.models.sms import SmsJob, SmsJobStatus
|
||||
|
||||
router = APIRouter(prefix="/api/sync", tags=["sync"])
|
||||
|
||||
@router.post("/poll")
|
||||
async def sync_poll(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Called by on-prem TapTrack every 30s.
|
||||
Returns pending SMS jobs and current config (sender_name, credits, feature flags).
|
||||
Body: { license_key: str, report_sent_ids: [str] } (completed job IDs to mark as sent)
|
||||
"""
|
||||
body = await request.json()
|
||||
key = body.get("license_key", "")
|
||||
sent_ids = body.get("report_sent_ids", [])
|
||||
|
||||
lic = (await db.execute(select(License).where(License.key == key))).scalar_one_or_none()
|
||||
if not lic or lic.status == LicenseStatus.revoked:
|
||||
raise HTTPException(403, "Invalid or revoked license")
|
||||
|
||||
school = (await db.execute(select(School).where(School.id == lic.school_id))).scalar_one_or_none()
|
||||
if not school:
|
||||
raise HTTPException(404)
|
||||
|
||||
# Mark completed jobs
|
||||
if sent_ids:
|
||||
await db.execute(
|
||||
update(SmsJob)
|
||||
.where(and_(SmsJob.id.in_(sent_ids), SmsJob.school_id == school.id))
|
||||
.values(status=SmsJobStatus.sent, sent_at=datetime.now(timezone.utc))
|
||||
)
|
||||
|
||||
# Get pending jobs (max 50 per poll)
|
||||
pending_jobs = (await db.execute(
|
||||
select(SmsJob)
|
||||
.where(and_(SmsJob.school_id == school.id, SmsJob.status == SmsJobStatus.pending))
|
||||
.limit(50)
|
||||
)).scalars().all()
|
||||
|
||||
# Mark as processing
|
||||
job_ids = [j.id for j in pending_jobs]
|
||||
if job_ids:
|
||||
await db.execute(
|
||||
update(SmsJob)
|
||||
.where(SmsJob.id.in_(job_ids))
|
||||
.values(status=SmsJobStatus.processing)
|
||||
)
|
||||
|
||||
# Update last seen
|
||||
lic.last_validated_at = datetime.now(timezone.utc)
|
||||
lic.last_seen_ip = request.client.host if request.client else None
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"sms_jobs": [
|
||||
{"id": j.id, "recipient_phone": j.recipient_phone,
|
||||
"message": j.message, "sender_name": j.sender_name}
|
||||
for j in pending_jobs
|
||||
],
|
||||
"config": {
|
||||
"sms_sender_name": school.sms_sender_name,
|
||||
"sms_credits": float(school.sms_credits),
|
||||
"school_status": school.status.value,
|
||||
},
|
||||
}
|
||||
149
backend/app/routers/tickets.py
Normal file
149
backend/app/routers/tickets.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""Support ticket endpoints."""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
|
||||
from app.auth.dependencies import require_super_admin, get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.user import HubUser, UserRole
|
||||
from app.models.ticket import SupportTicket, TicketReply, TicketStatus, TicketPriority, TicketCategory
|
||||
|
||||
router = APIRouter(prefix="/api/tickets", tags=["tickets"])
|
||||
|
||||
class TicketCreate(BaseModel):
|
||||
subject: str
|
||||
body: str
|
||||
category: TicketCategory = TicketCategory.general
|
||||
|
||||
class TicketUpdate(BaseModel):
|
||||
status: Optional[TicketStatus] = None
|
||||
priority: Optional[TicketPriority] = None
|
||||
assigned_to: Optional[str] = None
|
||||
|
||||
class ReplyCreate(BaseModel):
|
||||
body: str
|
||||
is_internal: bool = False
|
||||
|
||||
def _ticket_out(t: SupportTicket) -> dict:
|
||||
return {
|
||||
"id": t.id, "school_id": t.school_id, "ticket_number": t.ticket_number,
|
||||
"subject": t.subject, "body": t.body, "category": t.category.value,
|
||||
"status": t.status.value, "priority": t.priority.value,
|
||||
"assigned_to": t.assigned_to,
|
||||
"first_response_at": t.first_response_at.isoformat() if t.first_response_at else None,
|
||||
"resolved_at": t.resolved_at.isoformat() if t.resolved_at else None,
|
||||
"created_at": t.created_at.isoformat(),
|
||||
"updated_at": t.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
def _next_ticket_number(count: int) -> str:
|
||||
from datetime import date
|
||||
return f"TKT-{date.today().year}-{count + 1:05d}"
|
||||
|
||||
@router.get("")
|
||||
async def list_tickets(
|
||||
school_id: Optional[str] = Query(None),
|
||||
status: Optional[TicketStatus] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(25),
|
||||
current_user: HubUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(SupportTicket).order_by(desc(SupportTicket.updated_at))
|
||||
if current_user.role != UserRole.super_admin:
|
||||
stmt = stmt.where(SupportTicket.school_id == current_user.school_id)
|
||||
elif school_id:
|
||||
stmt = stmt.where(SupportTicket.school_id == school_id)
|
||||
if status:
|
||||
stmt = stmt.where(SupportTicket.status == status)
|
||||
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
|
||||
tickets = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all()
|
||||
return {"items": [_ticket_out(t) for t in tickets], "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_ticket(
|
||||
body: TicketCreate,
|
||||
current_user: HubUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not current_user.school_id:
|
||||
raise HTTPException(400, "No school associated with your account")
|
||||
count = (await db.execute(select(func.count()).select_from(SupportTicket))).scalar_one()
|
||||
ticket = SupportTicket(
|
||||
school_id=current_user.school_id,
|
||||
submitted_by=current_user.id,
|
||||
ticket_number=_next_ticket_number(count),
|
||||
subject=body.subject,
|
||||
body=body.body,
|
||||
category=body.category,
|
||||
)
|
||||
db.add(ticket)
|
||||
await db.commit()
|
||||
return _ticket_out(ticket)
|
||||
|
||||
@router.get("/{ticket_id}")
|
||||
async def get_ticket(
|
||||
ticket_id: str,
|
||||
current_user: HubUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
t = (await db.execute(select(SupportTicket).where(SupportTicket.id == ticket_id))).scalar_one_or_none()
|
||||
if not t:
|
||||
raise HTTPException(404)
|
||||
if current_user.role != UserRole.super_admin and t.school_id != current_user.school_id:
|
||||
raise HTTPException(403)
|
||||
replies_res = await db.execute(select(TicketReply).where(TicketReply.ticket_id == ticket_id).order_by(TicketReply.created_at))
|
||||
replies = replies_res.scalars().all()
|
||||
visible_replies = [r for r in replies if not r.is_internal or current_user.role == UserRole.super_admin]
|
||||
return {
|
||||
**_ticket_out(t),
|
||||
"replies": [
|
||||
{"id": r.id, "body": r.body, "is_internal": r.is_internal,
|
||||
"author_id": r.author_id, "created_at": r.created_at.isoformat()}
|
||||
for r in visible_replies
|
||||
],
|
||||
}
|
||||
|
||||
@router.put("/{ticket_id}")
|
||||
async def update_ticket(
|
||||
ticket_id: str,
|
||||
body: TicketUpdate,
|
||||
_admin: HubUser = Depends(require_super_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
t = (await db.execute(select(SupportTicket).where(SupportTicket.id == ticket_id))).scalar_one_or_none()
|
||||
if not t:
|
||||
raise HTTPException(404)
|
||||
for field, value in body.model_dump(exclude_none=True).items():
|
||||
setattr(t, field, value)
|
||||
if body.status in (TicketStatus.resolved, TicketStatus.closed) and not t.resolved_at:
|
||||
t.resolved_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
return _ticket_out(t)
|
||||
|
||||
@router.post("/{ticket_id}/replies", status_code=201)
|
||||
async def add_reply(
|
||||
ticket_id: str,
|
||||
body: ReplyCreate,
|
||||
current_user: HubUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
t = (await db.execute(select(SupportTicket).where(SupportTicket.id == ticket_id))).scalar_one_or_none()
|
||||
if not t:
|
||||
raise HTTPException(404)
|
||||
if current_user.role != UserRole.super_admin and t.school_id != current_user.school_id:
|
||||
raise HTTPException(403)
|
||||
is_internal = body.is_internal and current_user.role == UserRole.super_admin
|
||||
reply = TicketReply(ticket_id=ticket_id, author_id=current_user.id, body=body.body, is_internal=is_internal)
|
||||
db.add(reply)
|
||||
# Set first response time (super admin only)
|
||||
if current_user.role == UserRole.super_admin and not t.first_response_at:
|
||||
t.first_response_at = datetime.now(timezone.utc)
|
||||
if t.status == TicketStatus.open:
|
||||
t.status = TicketStatus.in_progress
|
||||
await db.commit()
|
||||
return {"id": reply.id, "body": reply.body, "created_at": reply.created_at.isoformat()}
|
||||
78
backend/app/routers/users.py
Normal file
78
backend/app/routers/users.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Hub user management — super admin only."""
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
|
||||
from app.auth.dependencies import require_super_admin
|
||||
from app.auth.password import hash_password
|
||||
from app.database import get_db
|
||||
from app.models.user import HubUser, UserRole
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
email: EmailStr
|
||||
full_name: str
|
||||
password: str
|
||||
role: UserRole = UserRole.school_admin
|
||||
school_id: Optional[str] = None
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
full_name: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
school_id: Optional[str] = None
|
||||
|
||||
@router.get("")
|
||||
async def list_users(
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(25),
|
||||
search: Optional[str] = Query(None),
|
||||
_admin: HubUser = Depends(require_super_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(HubUser).order_by(desc(HubUser.created_at))
|
||||
if search:
|
||||
stmt = stmt.where(HubUser.email.ilike(f"%{search}%") | HubUser.full_name.ilike(f"%{search}%"))
|
||||
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
|
||||
users = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all()
|
||||
return {
|
||||
"items": [{"id": u.id, "email": u.email, "full_name": u.full_name, "role": u.role.value,
|
||||
"school_id": u.school_id, "is_active": u.is_active,
|
||||
"created_at": u.created_at.isoformat()} for u in users],
|
||||
"total": total,
|
||||
}
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_user(
|
||||
body: UserCreate,
|
||||
_admin: HubUser = Depends(require_super_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
existing = (await db.execute(select(HubUser).where(HubUser.email == body.email))).scalar_one_or_none()
|
||||
if existing:
|
||||
raise HTTPException(409, "Email already exists")
|
||||
if len(body.password) < 8:
|
||||
raise HTTPException(422, "Password must be at least 8 characters")
|
||||
user = HubUser(email=body.email, full_name=body.full_name,
|
||||
hashed_password=hash_password(body.password),
|
||||
role=body.role, school_id=body.school_id)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
return {"id": user.id, "email": user.email, "full_name": user.full_name, "role": user.role.value}
|
||||
|
||||
@router.put("/{user_id}")
|
||||
async def update_user(
|
||||
user_id: str,
|
||||
body: UserUpdate,
|
||||
_admin: HubUser = Depends(require_super_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user = (await db.execute(select(HubUser).where(HubUser.id == user_id))).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(404)
|
||||
for field, value in body.model_dump(exclude_none=True).items():
|
||||
setattr(user, field, value)
|
||||
await db.commit()
|
||||
return {"id": user.id, "email": user.email, "is_active": user.is_active}
|
||||
Reference in New Issue
Block a user