"""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()}