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
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""Support ticket endpoints."""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
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
|
||||
@@ -14,45 +15,130 @@ from app.models.ticket import SupportTicket, TicketReply, TicketStatus, TicketPr
|
||||
|
||||
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:
|
||||
|
||||
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, "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,
|
||||
"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(),
|
||||
"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)
|
||||
@@ -60,9 +146,18 @@ async def list_tickets(
|
||||
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()
|
||||
return {"items": [_ticket_out(t) for t in tickets], "total": total, "page": page, "per_page": per_page}
|
||||
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(
|
||||
@@ -74,16 +169,17 @@ async def create_ticket(
|
||||
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,
|
||||
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)
|
||||
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(
|
||||
@@ -91,23 +187,25 @@ async def get_ticket(
|
||||
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_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]
|
||||
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),
|
||||
"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
|
||||
],
|
||||
**_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,
|
||||
@@ -125,6 +223,7 @@ async def update_ticket(
|
||||
await db.commit()
|
||||
return _ticket_out(t)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/replies", status_code=201)
|
||||
async def add_reply(
|
||||
ticket_id: str,
|
||||
@@ -140,10 +239,33 @@ async def add_reply(
|
||||
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()}
|
||||
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)}
|
||||
|
||||
Reference in New Issue
Block a user