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/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/auth/__init__.py
Normal file
0
backend/app/auth/__init__.py
Normal file
41
backend/app/auth/dependencies.py
Normal file
41
backend/app/auth/dependencies.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from jose import JWTError
|
||||
|
||||
from app.auth.jwt import decode_token
|
||||
from app.database import get_db
|
||||
from app.models.user import HubUser, UserRole
|
||||
|
||||
bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> HubUser:
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
try:
|
||||
payload = decode_token(credentials.credentials)
|
||||
user_id: str = payload.get("sub")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
result = await db.execute(select(HubUser).where(HubUser.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status_code=401, detail="User not found or inactive")
|
||||
return user
|
||||
|
||||
async def require_super_admin(user: HubUser = Depends(get_current_user)) -> HubUser:
|
||||
if user.role != UserRole.super_admin:
|
||||
raise HTTPException(status_code=403, detail="Super admin access required")
|
||||
return user
|
||||
|
||||
async def require_school_admin(user: HubUser = Depends(get_current_user)) -> HubUser:
|
||||
if user.role not in (UserRole.super_admin, UserRole.school_admin):
|
||||
raise HTTPException(status_code=403, detail="School admin access required")
|
||||
return user
|
||||
12
backend/app/auth/jwt.py
Normal file
12
backend/app/auth/jwt.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from jose import jwt, JWTError
|
||||
from app.config import settings
|
||||
|
||||
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
9
backend/app/auth/password.py
Normal file
9
backend/app/auth/password.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from passlib.context import CryptContext
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
return pwd_context.hash(plain)
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
25
backend/app/config.py
Normal file
25
backend/app/config.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import os
|
||||
|
||||
class Settings:
|
||||
SECRET_KEY: str = os.getenv("SECRET_KEY", "dev-secret-change-me")
|
||||
ENVIRONMENT: str = os.getenv("ENVIRONMENT", "development")
|
||||
DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub")
|
||||
REDIS_URL: str = os.getenv("REDIS_URL", "redis://redis:6379/0")
|
||||
SEMAPHORE_API_KEY: str = os.getenv("SEMAPHORE_API_KEY", "")
|
||||
SEMAPHORE_URL: str = "https://api.semaphore.co/api/v4/messages"
|
||||
SMTP_HOST: str = os.getenv("SMTP_HOST", "")
|
||||
SMTP_PORT: int = int(os.getenv("SMTP_PORT", "587"))
|
||||
SMTP_USER: str = os.getenv("SMTP_USER", "")
|
||||
SMTP_PASSWORD: str = os.getenv("SMTP_PASSWORD", "")
|
||||
SMTP_FROM: str = os.getenv("SMTP_FROM", "noreply@taptrack.io")
|
||||
HUB_BASE_URL: str = os.getenv("HUB_BASE_URL", "http://localhost:8080")
|
||||
|
||||
# JWT
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 # 24 hours
|
||||
ALGORITHM: str = "HS256"
|
||||
|
||||
# License
|
||||
LICENSE_KEY_PREFIX: str = "TTUB"
|
||||
DEFAULT_SMS_CREDITS: float = 0.0
|
||||
|
||||
settings = Settings()
|
||||
20
backend/app/database.py
Normal file
20
backend/app/database.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import os
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
DATABASE_URL = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub",
|
||||
)
|
||||
|
||||
engine = create_async_engine(DATABASE_URL, echo=False, pool_pre_ping=True)
|
||||
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
async with AsyncSessionLocal() as session:
|
||||
yield session
|
||||
50
backend/app/main.py
Normal file
50
backend/app/main.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""TapTrack Hub — FastAPI application entry point."""
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.database import engine, Base
|
||||
# Import all models so Alembic/SQLAlchemy picks them up
|
||||
from app.models import user, school, license, sms, billing, ticket, audit, announcement # noqa
|
||||
|
||||
from app.routers import auth, schools, licenses, sms as sms_router, billing as billing_router
|
||||
from app.routers import tickets, users, dashboard, school_portal, announcements, sync
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Create tables if not exists (dev convenience — use Alembic in prod)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
|
||||
app = FastAPI(
|
||||
title="TapTrack Hub",
|
||||
description="Cloud control plane for TapTrack on-prem deployments",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Routers
|
||||
app.include_router(auth.router)
|
||||
app.include_router(schools.router)
|
||||
app.include_router(licenses.router)
|
||||
app.include_router(sms_router.router)
|
||||
app.include_router(billing_router.router)
|
||||
app.include_router(tickets.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(school_portal.router)
|
||||
app.include_router(announcements.router)
|
||||
app.include_router(sync.router)
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok", "service": "taptrack-hub"}
|
||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
16
backend/app/models/announcement.py
Normal file
16
backend/app/models/announcement.py
Normal file
@@ -0,0 +1,16 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.database import Base
|
||||
|
||||
class Announcement(Base):
|
||||
__tablename__ = "announcements"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_by: Mapped[str | None] = mapped_column(String(36), ForeignKey("hub_users.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
21
backend/app/models/audit.py
Normal file
21
backend/app/models/audit.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Text, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.database import Base
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
school_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("schools.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
actor_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
actor_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
action: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
entity_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
entity_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
detail: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True)
|
||||
|
||||
school: Mapped["School | None"] = relationship("School", back_populates="audit_logs")
|
||||
68
backend/app/models/billing.py
Normal file
68
backend/app/models/billing.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import uuid
|
||||
import enum
|
||||
from datetime import datetime, timezone, date
|
||||
from sqlalchemy import String, Boolean, DateTime, Date, Enum as SAEnum, ForeignKey, Text, Numeric, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.database import Base
|
||||
|
||||
class InvoiceStatus(str, enum.Enum):
|
||||
draft = "draft"
|
||||
sent = "sent"
|
||||
paid = "paid"
|
||||
overdue = "overdue"
|
||||
cancelled = "cancelled"
|
||||
|
||||
class BillingCycle(str, enum.Enum):
|
||||
monthly = "monthly"
|
||||
annual = "annual"
|
||||
|
||||
class Invoice(Base):
|
||||
__tablename__ = "invoices"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
school_id: Mapped[str] = mapped_column(String(36), ForeignKey("schools.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
invoice_number: Mapped[str] = mapped_column(String(30), unique=True, nullable=False)
|
||||
status: Mapped[InvoiceStatus] = mapped_column(SAEnum(InvoiceStatus), default=InvoiceStatus.draft, nullable=False)
|
||||
billing_period_start: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
billing_period_end: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
subscription_amount: Mapped[float] = mapped_column(Numeric(12, 2), default=0.0, nullable=False)
|
||||
sms_credit_amount: Mapped[float] = mapped_column(Numeric(12, 2), default=0.0, nullable=False)
|
||||
other_amount: Mapped[float] = mapped_column(Numeric(12, 2), default=0.0, nullable=False)
|
||||
total_amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="PHP", nullable=False)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
payment_method: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
payment_reference: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
pdf_path: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
email_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
school: Mapped["School"] = relationship("School", back_populates="invoices")
|
||||
line_items: Mapped[list["InvoiceLineItem"]] = relationship("InvoiceLineItem", back_populates="invoice", cascade="all, delete-orphan")
|
||||
|
||||
class InvoiceLineItem(Base):
|
||||
__tablename__ = "invoice_line_items"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
invoice_id: Mapped[str] = mapped_column(String(36), ForeignKey("invoices.id", ondelete="CASCADE"), nullable=False)
|
||||
description: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
quantity: Mapped[float] = mapped_column(Numeric(10, 2), default=1.0, nullable=False)
|
||||
unit_price: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
|
||||
amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
|
||||
|
||||
invoice: Mapped[Invoice] = relationship("Invoice", back_populates="line_items")
|
||||
|
||||
class SchoolSubscription(Base):
|
||||
"""Stores billing plan per school."""
|
||||
__tablename__ = "school_subscriptions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
school_id: Mapped[str] = mapped_column(String(36), ForeignKey("schools.id", ondelete="CASCADE"), unique=True, nullable=False)
|
||||
cycle: Mapped[BillingCycle] = mapped_column(SAEnum(BillingCycle), default=BillingCycle.monthly, nullable=False)
|
||||
monthly_fee: Mapped[float] = mapped_column(Numeric(12, 2), default=0.0, nullable=False)
|
||||
sms_cost_per_message: Mapped[float] = mapped_column(Numeric(8, 4), default=1.0, nullable=False)
|
||||
next_billing_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
40
backend/app/models/license.py
Normal file
40
backend/app/models/license.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import uuid
|
||||
import enum
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timezone, date
|
||||
from sqlalchemy import String, Boolean, DateTime, Date, Enum as SAEnum, ForeignKey, Text, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.database import Base
|
||||
|
||||
class LicenseStatus(str, enum.Enum):
|
||||
active = "active"
|
||||
expired = "expired"
|
||||
revoked = "revoked"
|
||||
trial = "trial"
|
||||
|
||||
def generate_license_key() -> str:
|
||||
alphabet = string.ascii_uppercase + string.digits
|
||||
segments = ["TTUB"] + [
|
||||
"".join(secrets.choice(alphabet) for _ in range(5))
|
||||
for _ in range(3)
|
||||
]
|
||||
return "-".join(segments)
|
||||
|
||||
class License(Base):
|
||||
__tablename__ = "licenses"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
school_id: Mapped[str] = mapped_column(String(36), ForeignKey("schools.id", ondelete="CASCADE"), unique=True, nullable=False)
|
||||
key: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, default=generate_license_key, index=True)
|
||||
status: Mapped[LicenseStatus] = mapped_column(SAEnum(LicenseStatus), default=LicenseStatus.trial, nullable=False)
|
||||
tier: Mapped[str] = mapped_column(String(20), default="standard", nullable=False)
|
||||
issued_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
expires_at: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
last_validated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_seen_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||
hardware_fingerprint: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
max_students: Mapped[int] = mapped_column(Integer, default=500, nullable=False)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
school: Mapped["School"] = relationship("School", back_populates="license")
|
||||
45
backend/app/models/school.py
Normal file
45
backend/app/models/school.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import uuid
|
||||
import enum
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import String, Boolean, DateTime, Enum as SAEnum, Text, Numeric, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.database import Base
|
||||
|
||||
class SchoolStatus(str, enum.Enum):
|
||||
active = "active"
|
||||
suspended = "suspended"
|
||||
expired = "expired"
|
||||
pending = "pending"
|
||||
|
||||
class LicenseTier(str, enum.Enum):
|
||||
basic = "basic"
|
||||
standard = "standard"
|
||||
premium = "premium"
|
||||
|
||||
class School(Base):
|
||||
__tablename__ = "schools"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||
address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
contact_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
contact_phone: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
billing_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
status: Mapped[SchoolStatus] = mapped_column(SAEnum(SchoolStatus), default=SchoolStatus.pending, nullable=False)
|
||||
tier: Mapped[LicenseTier] = mapped_column(SAEnum(LicenseTier), default=LicenseTier.standard, nullable=False)
|
||||
student_limit: Mapped[int] = mapped_column(Integer, default=500, nullable=False)
|
||||
sms_sender_name: Mapped[str] = mapped_column(String(11), default="SCHOOL", nullable=False)
|
||||
sms_credits: Mapped[float] = mapped_column(Numeric(12, 2), default=0.0, nullable=False)
|
||||
sms_credit_low_threshold: Mapped[int] = mapped_column(Integer, default=50, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
admins: Mapped[list["HubUser"]] = relationship("HubUser", back_populates="school")
|
||||
license: Mapped["License | None"] = relationship("License", back_populates="school", uselist=False)
|
||||
sms_jobs: Mapped[list["SmsJob"]] = relationship("SmsJob", back_populates="school")
|
||||
invoices: Mapped[list["Invoice"]] = relationship("Invoice", back_populates="school")
|
||||
tickets: Mapped[list["SupportTicket"]] = relationship("SupportTicket", back_populates="school")
|
||||
audit_logs: Mapped[list["AuditLog"]] = relationship("AuditLog", back_populates="school")
|
||||
50
backend/app/models/sms.py
Normal file
50
backend/app/models/sms.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import uuid
|
||||
import enum
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import String, Boolean, DateTime, Enum as SAEnum, ForeignKey, Text, Numeric, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.database import Base
|
||||
|
||||
class SmsJobStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
processing = "processing"
|
||||
sent = "sent"
|
||||
failed = "failed"
|
||||
cancelled = "cancelled"
|
||||
|
||||
class SmsCreditTx(str, enum.Enum):
|
||||
topup = "topup"
|
||||
deduct = "deduct"
|
||||
refund = "refund"
|
||||
adjustment = "adjustment"
|
||||
|
||||
class SmsJob(Base):
|
||||
__tablename__ = "sms_jobs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
school_id: Mapped[str] = mapped_column(String(36), ForeignKey("schools.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
recipient_phone: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
message: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
sender_name: Mapped[str] = mapped_column(String(11), nullable=False)
|
||||
status: Mapped[SmsJobStatus] = mapped_column(SAEnum(SmsJobStatus), default=SmsJobStatus.pending, nullable=False, index=True)
|
||||
trigger: Mapped[str | None] = mapped_column(String(50), nullable=True) # "absent", "late", "manual"
|
||||
semaphore_message_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
retry_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
school: Mapped["School"] = relationship("School", back_populates="sms_jobs")
|
||||
|
||||
class SmsCreditLedger(Base):
|
||||
__tablename__ = "sms_credit_ledger"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
school_id: Mapped[str] = mapped_column(String(36), ForeignKey("schools.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tx_type: Mapped[SmsCreditTx] = mapped_column(SAEnum(SmsCreditTx), nullable=False)
|
||||
amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
|
||||
balance_after: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
reference_id: Mapped[str | None] = mapped_column(String(36), nullable=True) # invoice_id or sms_job_id
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
created_by: Mapped[str | None] = mapped_column(String(36), nullable=True) # hub_user_id
|
||||
58
backend/app/models/ticket.py
Normal file
58
backend/app/models/ticket.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import uuid
|
||||
import enum
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import String, Boolean, DateTime, Enum as SAEnum, ForeignKey, Text, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.database import Base
|
||||
|
||||
class TicketStatus(str, enum.Enum):
|
||||
open = "open"
|
||||
in_progress = "in_progress"
|
||||
resolved = "resolved"
|
||||
closed = "closed"
|
||||
|
||||
class TicketPriority(str, enum.Enum):
|
||||
low = "low"
|
||||
medium = "medium"
|
||||
high = "high"
|
||||
urgent = "urgent"
|
||||
|
||||
class TicketCategory(str, enum.Enum):
|
||||
billing = "billing"
|
||||
technical = "technical"
|
||||
sms = "sms"
|
||||
license = "license"
|
||||
general = "general"
|
||||
|
||||
class SupportTicket(Base):
|
||||
__tablename__ = "support_tickets"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
school_id: Mapped[str] = mapped_column(String(36), ForeignKey("schools.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
submitted_by: Mapped[str] = mapped_column(String(36), ForeignKey("hub_users.id", ondelete="SET NULL"), nullable=True)
|
||||
ticket_number: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
|
||||
subject: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
category: Mapped[TicketCategory] = mapped_column(SAEnum(TicketCategory), default=TicketCategory.general, nullable=False)
|
||||
status: Mapped[TicketStatus] = mapped_column(SAEnum(TicketStatus), default=TicketStatus.open, nullable=False)
|
||||
priority: Mapped[TicketPriority] = mapped_column(SAEnum(TicketPriority), default=TicketPriority.medium, nullable=False)
|
||||
assigned_to: Mapped[str | None] = mapped_column(String(36), ForeignKey("hub_users.id", ondelete="SET NULL"), nullable=True)
|
||||
first_response_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
school: Mapped["School"] = relationship("School", back_populates="tickets")
|
||||
replies: Mapped[list["TicketReply"]] = relationship("TicketReply", back_populates="ticket", cascade="all, delete-orphan")
|
||||
|
||||
class TicketReply(Base):
|
||||
__tablename__ = "ticket_replies"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
ticket_id: Mapped[str] = mapped_column(String(36), ForeignKey("support_tickets.id", ondelete="CASCADE"), nullable=False)
|
||||
author_id: Mapped[str] = mapped_column(String(36), ForeignKey("hub_users.id", ondelete="SET NULL"), nullable=True)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
is_internal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
ticket: Mapped[SupportTicket] = relationship("SupportTicket", back_populates="replies")
|
||||
25
backend/app/models/user.py
Normal file
25
backend/app/models/user.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import uuid
|
||||
import enum
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import String, Boolean, DateTime, Enum as SAEnum, ForeignKey, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.database import Base
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
super_admin = "super_admin"
|
||||
school_admin = "school_admin"
|
||||
|
||||
class HubUser(Base):
|
||||
__tablename__ = "hub_users"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[UserRole] = mapped_column(SAEnum(UserRole), nullable=False, default=UserRole.school_admin)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
school_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("schools.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
school: Mapped["School | None"] = relationship("School", back_populates="admins")
|
||||
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}
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
32
backend/app/services/email.py
Normal file
32
backend/app/services/email.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Simple SMTP email service."""
|
||||
import smtplib
|
||||
import logging
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def send_email(to: str, subject: str, body: str, html: str | None = None) -> bool:
|
||||
"""Send email via configured SMTP. Returns True on success."""
|
||||
if not settings.SMTP_HOST:
|
||||
logger.warning(f"SMTP not configured — would send to {to}: {subject}")
|
||||
return False
|
||||
try:
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = settings.SMTP_FROM
|
||||
msg["To"] = to
|
||||
msg.attach(MIMEText(body, "plain"))
|
||||
if html:
|
||||
msg.attach(MIMEText(html, "html"))
|
||||
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as smtp:
|
||||
smtp.starttls()
|
||||
if settings.SMTP_USER:
|
||||
smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
smtp.sendmail(settings.SMTP_FROM, [to], msg.as_string())
|
||||
logger.info(f"Email sent to {to}: {subject}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Email failed to {to}: {e}")
|
||||
return False
|
||||
0
backend/app/tasks/__init__.py
Normal file
0
backend/app/tasks/__init__.py
Normal file
125
backend/app/tasks/billing.py
Normal file
125
backend/app/tasks/billing.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Celery tasks: invoice generation, email, overdue checks."""
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
|
||||
from app.worker import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _make_session():
|
||||
import os
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub")
|
||||
engine = create_engine(db_url.replace("postgresql+asyncpg://", "postgresql://"), pool_pre_ping=True)
|
||||
return sessionmaker(bind=engine)()
|
||||
|
||||
@celery_app.task(name="billing.generate_monthly_invoices")
|
||||
def generate_monthly_invoices():
|
||||
"""On the 1st: create draft invoices for all active schools with a subscription."""
|
||||
from app.models.billing import Invoice, SchoolSubscription, InvoiceLineItem, BillingCycle
|
||||
from app.models.school import School, SchoolStatus
|
||||
from sqlalchemy import select
|
||||
from datetime import date
|
||||
|
||||
db = _make_session()
|
||||
try:
|
||||
today = date.today()
|
||||
period_start = date(today.year, today.month, 1)
|
||||
prev_month = (period_start - timedelta(days=1))
|
||||
billing_start = date(prev_month.year, prev_month.month, 1)
|
||||
billing_end = period_start - timedelta(days=1)
|
||||
|
||||
subs = db.execute(select(SchoolSubscription).where(SchoolSubscription.is_active == True)).scalars().all()
|
||||
count = db.execute(select(func.count()).select_from(Invoice)).scalar_one()
|
||||
|
||||
for sub in subs:
|
||||
school = db.get(School, sub.school_id)
|
||||
if not school or school.status != SchoolStatus.active:
|
||||
continue
|
||||
total = float(sub.monthly_fee)
|
||||
inv_num = f"INV-{billing_start.strftime('%Y%m')}-{count + 1:04d}"
|
||||
count += 1
|
||||
inv = Invoice(
|
||||
school_id=sub.school_id,
|
||||
invoice_number=inv_num,
|
||||
billing_period_start=billing_start,
|
||||
billing_period_end=billing_end,
|
||||
subscription_amount=float(sub.monthly_fee),
|
||||
total_amount=total,
|
||||
due_date=period_start + timedelta(days=14),
|
||||
)
|
||||
db.add(inv)
|
||||
db.flush()
|
||||
db.add(InvoiceLineItem(
|
||||
invoice_id=inv.id,
|
||||
description=f"Monthly subscription — {school.name}",
|
||||
quantity=1,
|
||||
unit_price=float(sub.monthly_fee),
|
||||
amount=float(sub.monthly_fee),
|
||||
))
|
||||
db.commit()
|
||||
logger.info(f"Generated {len(subs)} invoices for {billing_start}")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"generate_monthly_invoices error: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@celery_app.task(name="billing.send_invoice_email")
|
||||
def send_invoice_email_task(invoice_id: str):
|
||||
"""Send invoice email to school billing contact."""
|
||||
from app.models.billing import Invoice, InvoiceStatus
|
||||
from app.models.school import School
|
||||
from app.services.email import send_email
|
||||
from sqlalchemy import select
|
||||
|
||||
db = _make_session()
|
||||
try:
|
||||
inv = db.get(Invoice, invoice_id)
|
||||
if not inv:
|
||||
return
|
||||
school = db.get(School, inv.school_id)
|
||||
if not school or not school.billing_email:
|
||||
return
|
||||
body = f"""Dear {school.contact_name or school.name},
|
||||
|
||||
Please find your invoice {inv.invoice_number} for the period {inv.billing_period_start} to {inv.billing_period_end}.
|
||||
|
||||
Amount Due: PHP {float(inv.total_amount):,.2f}
|
||||
Due Date: {inv.due_date}
|
||||
|
||||
Please log in to your TapTrack Hub portal to view and pay your invoice.
|
||||
|
||||
Thank you,
|
||||
TapTrack Hub Team
|
||||
"""
|
||||
send_email(to=school.billing_email, subject=f"Invoice {inv.invoice_number} — TapTrack Hub", body=body)
|
||||
from datetime import datetime, timezone
|
||||
inv.email_sent_at = datetime.now(timezone.utc)
|
||||
if inv.status.value == "draft":
|
||||
inv.status = InvoiceStatus.sent
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@celery_app.task(name="billing.check_overdue")
|
||||
def check_overdue():
|
||||
"""Mark overdue invoices and send warning emails."""
|
||||
from app.models.billing import Invoice, InvoiceStatus
|
||||
from sqlalchemy import select, and_
|
||||
|
||||
db = _make_session()
|
||||
try:
|
||||
today = date.today()
|
||||
overdue = db.execute(
|
||||
select(Invoice).where(
|
||||
and_(Invoice.status == InvoiceStatus.sent, Invoice.due_date < today, Invoice.due_date != None)
|
||||
)
|
||||
).scalars().all()
|
||||
for inv in overdue:
|
||||
inv.status = InvoiceStatus.overdue
|
||||
db.commit()
|
||||
logger.info(f"Marked {len(overdue)} invoices as overdue")
|
||||
finally:
|
||||
db.close()
|
||||
41
backend/app/tasks/license.py
Normal file
41
backend/app/tasks/license.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Celery task: license expiry checks and alerts."""
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
|
||||
from app.worker import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@celery_app.task(name="license.check_expiry")
|
||||
def check_expiry():
|
||||
"""Send expiry warning emails for licenses expiring in 30, 14, or 7 days."""
|
||||
import os
|
||||
from sqlalchemy import create_engine, select, and_
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.models.license import License, LicenseStatus
|
||||
from app.models.school import School
|
||||
from app.services.email import send_email
|
||||
|
||||
db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub")
|
||||
engine = create_engine(db_url.replace("postgresql+asyncpg://", "postgresql://"), pool_pre_ping=True)
|
||||
db = sessionmaker(bind=engine)()
|
||||
try:
|
||||
today = date.today()
|
||||
for days_ahead in [30, 14, 7]:
|
||||
target = today + timedelta(days=days_ahead)
|
||||
expiring = db.execute(
|
||||
select(License).where(
|
||||
and_(License.expires_at == target, License.status == LicenseStatus.active)
|
||||
)
|
||||
).scalars().all()
|
||||
for lic in expiring:
|
||||
school = db.get(School, lic.school_id)
|
||||
if school and school.billing_email:
|
||||
send_email(
|
||||
to=school.billing_email,
|
||||
subject=f"[TapTrack Hub] License expires in {days_ahead} days — {school.name}",
|
||||
body=f"Your TapTrack license for {school.name} expires on {lic.expires_at}. Please contact us to renew.",
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
35
backend/app/tasks/reports.py
Normal file
35
backend/app/tasks/reports.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Celery task: send monthly reports to schools."""
|
||||
import logging
|
||||
from app.worker import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@celery_app.task(name="reports.send_monthly_reports")
|
||||
def send_monthly_reports():
|
||||
"""Send monthly attendance and SMS report email to each active school."""
|
||||
import os
|
||||
from datetime import date, timedelta
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.models.school import School, SchoolStatus
|
||||
from app.services.email import send_email
|
||||
|
||||
db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub")
|
||||
engine = create_engine(db_url.replace("postgresql+asyncpg://", "postgresql://"), pool_pre_ping=True)
|
||||
db = sessionmaker(bind=engine)()
|
||||
try:
|
||||
today = date.today()
|
||||
prev_month_end = date(today.year, today.month, 1) - timedelta(days=1)
|
||||
prev_month_start = date(prev_month_end.year, prev_month_end.month, 1)
|
||||
schools = db.execute(select(School).where(School.status == SchoolStatus.active)).scalars().all()
|
||||
for school in schools:
|
||||
if not school.billing_email:
|
||||
continue
|
||||
send_email(
|
||||
to=school.billing_email,
|
||||
subject=f"Monthly Report — {school.name} — {prev_month_start.strftime('%B %Y')}",
|
||||
body=f"Dear {school.contact_name or school.name},\n\nPlease find your monthly summary for {prev_month_start.strftime('%B %Y')} in your TapTrack Hub portal.\n\nSMS Credits Remaining: {float(school.sms_credits):.0f}\n\nLog in to view full details.\n\nThank you,\nTapTrack Hub Team",
|
||||
)
|
||||
logger.info(f"Sent monthly reports to {len(schools)} schools")
|
||||
finally:
|
||||
db.close()
|
||||
102
backend/app/tasks/sms.py
Normal file
102
backend/app/tasks/sms.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Celery task: process pending SMS jobs via Semaphore."""
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import create_engine, select, update, and_
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.worker import celery_app
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _make_sync_engine():
|
||||
db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub")
|
||||
return create_engine(db_url.replace("postgresql+asyncpg://", "postgresql://"), pool_pre_ping=True, pool_size=2)
|
||||
|
||||
_engine = _make_sync_engine()
|
||||
_Session = sessionmaker(bind=_engine)
|
||||
|
||||
@celery_app.task(name="sms.process_queue")
|
||||
def process_sms_queue():
|
||||
"""Process up to 20 pending SMS jobs per run via Semaphore API."""
|
||||
from app.models.sms import SmsJob, SmsJobStatus
|
||||
from app.models.school import School
|
||||
|
||||
db = _Session()
|
||||
try:
|
||||
jobs = db.execute(
|
||||
select(SmsJob).where(SmsJob.status == SmsJobStatus.pending).limit(20)
|
||||
).scalars().all()
|
||||
|
||||
for job in jobs:
|
||||
school = db.get(School, job.school_id)
|
||||
if not school or float(school.sms_credits) <= 0:
|
||||
job.status = SmsJobStatus.cancelled
|
||||
job.error_message = "Insufficient credits"
|
||||
db.commit()
|
||||
continue
|
||||
|
||||
result = _send_semaphore(job.recipient_phone, job.message, job.sender_name)
|
||||
if result["success"]:
|
||||
job.status = SmsJobStatus.sent
|
||||
job.sent_at = datetime.now(timezone.utc)
|
||||
job.semaphore_message_id = result.get("message_id")
|
||||
# Deduct credit
|
||||
from app.models.sms import SmsCreditLedger, SmsCreditTx
|
||||
school.sms_credits = float(school.sms_credits) - 1.0
|
||||
db.add(SmsCreditLedger(
|
||||
school_id=school.id,
|
||||
tx_type=SmsCreditTx.deduct,
|
||||
amount=-1.0,
|
||||
balance_after=float(school.sms_credits),
|
||||
description=f"SMS sent to {job.recipient_phone}",
|
||||
reference_id=job.id,
|
||||
))
|
||||
# Low credit alert
|
||||
if float(school.sms_credits) <= school.sms_credit_low_threshold:
|
||||
send_low_credit_alert.delay(school.id)
|
||||
else:
|
||||
job.retry_count += 1
|
||||
if job.retry_count >= 5:
|
||||
job.status = SmsJobStatus.failed
|
||||
job.error_message = result.get("error")
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def _send_semaphore(phone: str, message: str, sender: str) -> dict:
|
||||
try:
|
||||
with httpx.Client(timeout=15) as client:
|
||||
resp = client.post(settings.SEMAPHORE_URL, data={
|
||||
"apikey": settings.SEMAPHORE_API_KEY,
|
||||
"number": phone,
|
||||
"message": message,
|
||||
"sendername": sender,
|
||||
})
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
msg_id = str(data[0].get("message_id", "")) if isinstance(data, list) and data else None
|
||||
return {"success": True, "message_id": msg_id}
|
||||
return {"success": False, "error": f"HTTP {resp.status_code}"}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@celery_app.task(name="sms.send_low_credit_alert")
|
||||
def send_low_credit_alert(school_id: str):
|
||||
"""Send low credit warning email to school billing contact."""
|
||||
from app.services.email import send_email
|
||||
from app.models.school import School
|
||||
db = _Session()
|
||||
try:
|
||||
school = db.get(School, school_id)
|
||||
if school and school.billing_email:
|
||||
send_email(
|
||||
to=school.billing_email,
|
||||
subject=f"[TapTrack Hub] Low SMS Credits — {school.name}",
|
||||
body=f"Your SMS credit balance for {school.name} is low ({float(school.sms_credits):.0f} remaining). Please top up to continue sending SMS notifications.",
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
55
backend/app/worker.py
Normal file
55
backend/app/worker.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Celery worker + beat schedule for TapTrack Hub."""
|
||||
import os
|
||||
from celery import Celery
|
||||
from celery.schedules import crontab
|
||||
|
||||
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
|
||||
|
||||
celery_app = Celery(
|
||||
"taptrack_hub",
|
||||
broker=REDIS_URL,
|
||||
backend=REDIS_URL,
|
||||
include=[
|
||||
"app.tasks.sms",
|
||||
"app.tasks.billing",
|
||||
"app.tasks.reports",
|
||||
"app.tasks.license",
|
||||
],
|
||||
)
|
||||
|
||||
celery_app.conf.update(
|
||||
task_serializer="json",
|
||||
accept_content=["json"],
|
||||
result_serializer="json",
|
||||
timezone="Asia/Manila",
|
||||
enable_utc=True,
|
||||
beat_schedule={
|
||||
# Process pending SMS jobs every 30 seconds
|
||||
"process-sms-queue": {
|
||||
"task": "sms.process_queue",
|
||||
"schedule": 30.0,
|
||||
},
|
||||
# Check license expiry every day at 8am
|
||||
"check-license-expiry": {
|
||||
"task": "license.check_expiry",
|
||||
"schedule": crontab(hour=8, minute=0),
|
||||
},
|
||||
# Generate monthly invoices on the 1st at 6am
|
||||
"generate-monthly-invoices": {
|
||||
"task": "billing.generate_monthly_invoices",
|
||||
"schedule": crontab(day_of_month=1, hour=6, minute=0),
|
||||
},
|
||||
# Send monthly reports on the 1st at 7am
|
||||
"send-monthly-reports": {
|
||||
"task": "reports.send_monthly_reports",
|
||||
"schedule": crontab(day_of_month=1, hour=7, minute=0),
|
||||
},
|
||||
# Check for overdue invoices daily at 9am
|
||||
"check-overdue-invoices": {
|
||||
"task": "billing.check_overdue",
|
||||
"schedule": crontab(hour=9, minute=0),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
app = celery_app
|
||||
Reference in New Issue
Block a user