Files
TapTrack-Hub/backend/app/routers/tickets.py
kevin-asprec 9ef8f1a421 feat(phases-10-15): complete TapTrack Hub v1.0
Phase 10 — Support Ticket System:
- tickets.py router: SLA status (on_track/at_risk/breached/responded), email
  notifications on create+reply via background threads, school_name in list,
  priority filter, bulk-close endpoint
- tasks/tickets.py: escalate_stale Celery task (48h→high, 72h no reply→urgent)
- worker.py: escalate_stale scheduled every hour
- templates/email/ticket_notification.html: HTML ticket notification email
- TicketsPage.vue: status tabs, SLA badge, priority badge, school name column,
  checkbox bulk-close, pagination
- TicketDetailPage.vue: inline priority/status/assignee selectors, SLA timer,
  internal note lock icon, closed-ticket guard

Phase 11 — Monthly Report Generation:
- models/report.py: MonthlyReport + SchoolMonthlyStats ORM models
- tasks/reports.py: send_monthly_reports enhanced with SMS stats, attendance
  data, invoice summary, stores MonthlyReport record per school per month

Phase 12 — On-Prem Monthly Report Pull:
- tasks/reports.py: pull_monthly_stats task — httpx GET to each school's
  hub_base_url, upserts SchoolMonthlyStats; runs 1st at 5am
- worker.py: pull_monthly_stats scheduled 1st at 5am

Phase 13 — Feature Flags + Suspension:
- models/school.py: hub_base_url, feature_overrides (JSON), onboarding_completed_at
- routers/schools.py: PUT /{id}/feature-overrides endpoint
- routers/sync.py: _tier_features() merges school.feature_overrides into poll config

Phase 14 — Onboarding Wizard + Welcome Email:
- tasks/onboarding.py: send_welcome_email Celery task with license key
- routers/schools.py: auto-trigger welcome email on POST /schools,
  POST /{id}/activate (status→active + onboarding_completed_at),
  POST /{id}/resend-welcome

Phase 15 — UX Polish + Ops Tools:
- routers/search.py: GET /api/search?q= (schools + invoices + tickets, 5 each)
- routers/audit.py: GET /api/audit-logs (paginated, filterable)
- AppLayout.vue: global search bar with debounced dropdown, result navigation
- AuditLogsPage.vue: new page with filter + pagination
- AppSidebar.vue: Audit Logs nav item added
- router/index.ts: /audit-logs route
- api.ts: globalSearch, getAuditLogs, activateSchool, resendWelcomeEmail,
  updateFeatureOverrides, bulkCloseTickets

Deployment:
- docker-compose.yml: x-backend-env anchor (DRY), PDF_DIR env var,
  seed service (one-shot python seed.py on first boot)
- migrations/003_phases11_15.py: monthly_reports, school_monthly_stats tables
  + schools hub_base_url/feature_overrides/onboarding_completed_at columns
2026-03-16 14:28:52 +08:00

272 lines
11 KiB
Python

"""Support ticket endpoints."""
import os
import threading
from datetime import datetime, timezone, timedelta
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
class BulkClose(BaseModel):
ticket_ids: list[str]
def _sla_status(ticket: SupportTicket) -> str:
if ticket.status in (TicketStatus.resolved, TicketStatus.closed):
return "resolved"
if ticket.first_response_at:
return "responded"
age_h = (datetime.now(timezone.utc) - ticket.created_at).total_seconds() / 3600
if age_h > 48:
return "breached"
if age_h > 24:
return "at_risk"
return "on_track"
def _ticket_out(t: SupportTicket, school_name: str | None = None) -> dict:
return {
"id": t.id, "school_id": t.school_id, "school_name": school_name,
"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, "sla_status": _sla_status(t),
"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}"
def _notify_async(ticket_id: str, message_body: str, is_reply: bool, from_admin: bool):
"""Send ticket email notifications in a background thread."""
import os as _os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models.ticket import SupportTicket
from app.models.school import School
from app.models.user import HubUser, UserRole
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:
t = db.get(SupportTicket, ticket_id)
if not t:
return
hub_url = _os.getenv("HUB_BASE_URL", "http://localhost:8090")
ticket_url = f"{hub_url}/tickets/{t.id}"
base_ctx = {
"ticket_number": t.ticket_number, "subject": t.subject,
"category": t.category.value, "priority": t.priority.value,
"status": t.status.value, "message_body": message_body, "ticket_url": ticket_url,
}
if is_reply and from_admin:
school = db.get(School, t.school_id)
if school and school.billing_email:
send_email(
to=school.billing_email,
subject=f"[TapTrack] Reply on {t.ticket_number}: {t.subject}",
body=f"Support replied to ticket {t.ticket_number}.",
template_name="email/ticket_notification.html",
context={**base_ctx, "email_title": f"Reply on Ticket {t.ticket_number}",
"recipient_name": school.contact_name or school.name, "message_label": "Support Reply"},
email_type="other", school_id=t.school_id,
)
else:
admins = db.execute(
select(HubUser).where(HubUser.role == UserRole.super_admin, HubUser.is_active == True)
).scalars().all()
title = f"New Ticket: {t.ticket_number}" if not is_reply else f"Customer Reply on {t.ticket_number}"
for admin in admins:
send_email(
to=admin.email,
subject=f"[TapTrack] {title}: {t.subject}",
body=f"{title}\n\n{message_body}",
template_name="email/ticket_notification.html",
context={**base_ctx, "email_title": title,
"recipient_name": admin.full_name,
"message_label": "Message" if not is_reply else "Customer Reply"},
email_type="other", school_id=t.school_id,
)
except Exception as e:
import logging
logging.getLogger(__name__).warning("Ticket notification error: %s", e)
finally:
db.close()
engine.dispose()
@router.get("")
async def list_tickets(
school_id: Optional[str] = Query(None),
status: Optional[TicketStatus] = Query(None),
priority: Optional[TicketPriority] = 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),
):
from app.models.school import School
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)
if priority:
stmt = stmt.where(SupportTicket.priority == priority)
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()
sids = {t.school_id for t in tickets}
school_map: dict[str, str] = {}
if sids:
schools = (await db.execute(select(School).where(School.id.in_(sids)))).scalars().all()
school_map = {s.id: s.name for s in schools}
return {"items": [_ticket_out(t, school_map.get(t.school_id)) 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()
threading.Thread(target=_notify_async, args=(ticket.id, body.body, False, False), daemon=True).start()
from app.models.school import School
school = (await db.execute(select(School).where(School.id == current_user.school_id))).scalar_one_or_none()
return _ticket_out(ticket, school.name if school else None)
@router.get("/{ticket_id}")
async def get_ticket(
ticket_id: str,
current_user: HubUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
from app.models.school import School
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 = (await db.execute(
select(TicketReply).where(TicketReply.ticket_id == ticket_id).order_by(TicketReply.created_at)
)).scalars().all()
visible = [r for r in replies if not r.is_internal or current_user.role == UserRole.super_admin]
school = (await db.execute(select(School).where(School.id == t.school_id))).scalar_one_or_none()
return {
**_ticket_out(t, school.name if school else None),
"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],
}
@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)
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()
if not is_internal:
from_admin = current_user.role == UserRole.super_admin
threading.Thread(target=_notify_async, args=(t.id, body.body, True, from_admin), daemon=True).start()
return {"id": reply.id, "body": reply.body, "is_internal": is_internal, "created_at": reply.created_at.isoformat()}
@router.post("/bulk-close")
async def bulk_close_tickets(
body: BulkClose,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
now = datetime.now(timezone.utc)
tickets = (await db.execute(
select(SupportTicket).where(SupportTicket.id.in_(body.ticket_ids))
)).scalars().all()
closed = 0
for t in tickets:
if t.status != TicketStatus.closed:
t.status = TicketStatus.closed
if not t.resolved_at:
t.resolved_at = now
closed += 1
await db.commit()
return {"closed": closed, "total": len(tickets)}