- Ticket CRUD with full 15-status lifecycle enforcement
- Auto-generated ticket numbers (PAV-YYYY-NNNNN)
- Status transitions validated and auto-logged to timeline
- Reopen with 7-day window + auto-escalation to Ama
- SLA engine: priority-based deadlines and breach detection
- Photo uploads to uploads/ with multipart support
- Category system seeded from PRD §7 taxonomy (20 top-level)
- Flat and tree category listing endpoints
- SLA status check endpoint (/api/tickets/{id}/sla)
- Paginated ticket listing with filters
278 lines
10 KiB
Python
278 lines
10 KiB
Python
"""Ticket CRUD endpoints, photo uploads, SLA checks, and category listing."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import uuid
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, status
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.core.config import settings
|
|
from app.core.database import get_db
|
|
from app.core.security import get_current_user, require_roles
|
|
from app.models.category import Category
|
|
from app.models.ticket import Ticket, TicketPhoto
|
|
from app.models.user import User
|
|
from app.schemas.ticket import (
|
|
CategoryOut,
|
|
CategoryTreeOut,
|
|
SLAStatusOut,
|
|
TicketBrief,
|
|
TicketCreate,
|
|
TicketListResponse,
|
|
TicketOut,
|
|
TicketPhotoOut,
|
|
TicketTimelineOut,
|
|
TicketUpdate,
|
|
)
|
|
from app.services import ticket as ticket_service
|
|
from app.services.sla import get_sla_status
|
|
|
|
router = APIRouter(prefix="/api/tickets", tags=["tickets"])
|
|
|
|
# Ensure uploads directory exists
|
|
UPLOADS_DIR = settings.BASE_DIR / "uploads"
|
|
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
# ── Categories ──────────────────────────────────────────────────────
|
|
async def _build_category_tree(db: AsyncSession, parent_id: int | None = None) -> list[CategoryTreeOut]:
|
|
"""Build a nested category tree."""
|
|
result = await db.execute(
|
|
select(Category)
|
|
.where(Category.parent_id == parent_id)
|
|
.order_by(Category.name)
|
|
)
|
|
categories = result.scalars().all()
|
|
tree = []
|
|
for cat in categories:
|
|
children = await _build_category_tree(db, cat.id)
|
|
tree.append(CategoryTreeOut(
|
|
id=cat.id,
|
|
type=cat.type,
|
|
name=cat.name,
|
|
parent_id=cat.parent_id,
|
|
sla_urgency=cat.sla_urgency,
|
|
children=children,
|
|
))
|
|
return tree
|
|
|
|
|
|
@router.get("/categories", response_model=list[CategoryTreeOut])
|
|
async def list_categories(
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
type_filter: str | None = Query(None, alias="type"),
|
|
) -> list[CategoryTreeOut]:
|
|
"""Return the full category tree, optionally filtered by type (maintenance, cs, emergency)."""
|
|
if type_filter:
|
|
# Return flat list of top-level categories of the given type
|
|
result = await db.execute(
|
|
select(Category)
|
|
.where(Category.parent_id.is_(None), Category.type == type_filter)
|
|
.order_by(Category.name)
|
|
)
|
|
parents = result.scalars().all()
|
|
tree = []
|
|
for parent in parents:
|
|
children_result = await db.execute(
|
|
select(Category)
|
|
.where(Category.parent_id == parent.id)
|
|
.order_by(Category.name)
|
|
)
|
|
children = children_result.scalars().all()
|
|
tree.append(CategoryTreeOut(
|
|
id=parent.id,
|
|
type=parent.type,
|
|
name=parent.name,
|
|
parent_id=parent.parent_id,
|
|
sla_urgency=parent.sla_urgency,
|
|
children=[CategoryOut.model_validate(c) for c in children],
|
|
))
|
|
return tree
|
|
return await _build_category_tree(db)
|
|
|
|
|
|
@router.get("/categories/flat", response_model=list[CategoryOut])
|
|
async def list_categories_flat(
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
type_filter: str | None = Query(None, alias="type"),
|
|
) -> list[CategoryOut]:
|
|
"""Return a flat list of all categories (no nesting), optionally filtered by type."""
|
|
query = select(Category).order_by(Category.type, Category.name)
|
|
if type_filter:
|
|
query = query.where(Category.type == type_filter)
|
|
result = await db.execute(query)
|
|
categories = result.scalars().all()
|
|
return [CategoryOut.model_validate(c) for c in categories]
|
|
|
|
|
|
# ── Ticket CRUD ──────────────────────────────────────────────────────
|
|
@router.post("", response_model=TicketOut, status_code=status.HTTP_201_CREATED)
|
|
async def create_ticket(
|
|
body: TicketCreate,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(get_current_user)],
|
|
) -> Ticket:
|
|
"""Create a new ticket. Auto-generates ticket number PAV-YYYY-NNNNN."""
|
|
ticket = await ticket_service.create_ticket(db, body.model_dump(exclude_none=True), user=current_user)
|
|
# Reload with relationships
|
|
ticket = await ticket_service.get_ticket(db, ticket.id)
|
|
sla = await get_sla_status(ticket)
|
|
ticket.sla_status = sla # type: ignore[attr-defined]
|
|
return ticket
|
|
|
|
|
|
@router.get("", response_model=TicketListResponse)
|
|
async def list_tickets(
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(50, ge=1, le=200),
|
|
status: str | None = Query(None),
|
|
priority: str | None = Query(None),
|
|
property: str | None = Query(None),
|
|
category_id: int | None = Query(None),
|
|
assigned_to: int | None = Query(None),
|
|
date_from: datetime | None = Query(None),
|
|
date_to: datetime | None = Query(None),
|
|
) -> TicketListResponse:
|
|
"""List tickets with optional filtering and pagination."""
|
|
tickets, total = await ticket_service.list_tickets(
|
|
db,
|
|
status_filter=status,
|
|
priority_filter=priority,
|
|
property_filter=property,
|
|
category_id=category_id,
|
|
assigned_to=assigned_to,
|
|
date_from=date_from,
|
|
date_to=date_to,
|
|
page=page,
|
|
page_size=page_size,
|
|
)
|
|
items = [TicketBrief.model_validate(t) for t in tickets]
|
|
return TicketListResponse(items=items, total=total, page=page, page_size=page_size)
|
|
|
|
|
|
@router.get("/{ticket_id}", response_model=TicketOut)
|
|
async def get_ticket(
|
|
ticket_id: int,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
) -> Ticket:
|
|
"""Get a single ticket by ID with full details, timeline, photos, and SLA status."""
|
|
ticket = await ticket_service.get_ticket(db, ticket_id)
|
|
sla = await get_sla_status(ticket)
|
|
ticket.sla_status = sla # type: ignore[attr-defined]
|
|
return ticket
|
|
|
|
|
|
@router.patch("/{ticket_id}", response_model=TicketOut)
|
|
async def update_ticket(
|
|
ticket_id: int,
|
|
body: TicketUpdate,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(get_current_user)],
|
|
) -> Ticket:
|
|
"""Update a ticket. Validates status transitions and logs timeline."""
|
|
ticket = await ticket_service.update_ticket(db, ticket_id, body.model_dump(exclude_none=True), user=current_user)
|
|
sla = await get_sla_status(ticket)
|
|
ticket.sla_status = sla # type: ignore[attr-defined]
|
|
return ticket
|
|
|
|
|
|
# ── Status Transitions (convenience endpoints) ───────────────────────
|
|
@router.post("/{ticket_id}/status", response_model=TicketOut)
|
|
async def change_ticket_status(
|
|
ticket_id: int,
|
|
body: dict,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(get_current_user)],
|
|
) -> Ticket:
|
|
"""Change a ticket's status with optional note.
|
|
|
|
Request body:
|
|
```json
|
|
{"status": "Logged", "note": "Verified with customer"}
|
|
```
|
|
"""
|
|
status_val = body.get("status")
|
|
if not status_val:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="status is required")
|
|
update_data = {"status": status_val}
|
|
if "note" in body:
|
|
update_data["note"] = body["note"]
|
|
|
|
ticket = await ticket_service.update_ticket(db, ticket_id, update_data, user=current_user)
|
|
sla = await get_sla_status(ticket)
|
|
ticket.sla_status = sla # type: ignore[attr-defined]
|
|
return ticket
|
|
|
|
|
|
# ── SLA ──────────────────────────────────────────────────────────────
|
|
@router.get("/{ticket_id}/sla", response_model=SLAStatusOut)
|
|
async def check_sla(
|
|
ticket_id: int,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
) -> dict:
|
|
"""Check SLA status for a ticket — response deadline, resolution deadline, breach flags."""
|
|
ticket = await ticket_service.get_ticket(db, ticket_id)
|
|
return await get_sla_status(ticket)
|
|
|
|
|
|
# ── Photo Uploads ────────────────────────────────────────────────────
|
|
@router.post("/{ticket_id}/photos", response_model=list[TicketPhotoOut], status_code=status.HTTP_201_CREATED)
|
|
async def upload_photos(
|
|
ticket_id: int,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(get_current_user)],
|
|
files: list[UploadFile],
|
|
is_before: bool = True,
|
|
) -> list[TicketPhoto]:
|
|
"""Upload photos for a ticket (before/after). Stores files under uploads/."""
|
|
# Verify ticket exists
|
|
await ticket_service.get_ticket(db, ticket_id)
|
|
|
|
created_photos: list[TicketPhoto] = []
|
|
for file in files:
|
|
# Generate unique filename
|
|
ext = Path(file.filename or "photo.jpg").suffix if file.filename else ".jpg"
|
|
unique_name = f"{uuid.uuid4().hex}{ext}"
|
|
file_path = UPLOADS_DIR / unique_name
|
|
|
|
# Write file
|
|
content = await file.read()
|
|
file_path.write_bytes(content)
|
|
|
|
# Database record
|
|
photo = TicketPhoto(
|
|
ticket_id=ticket_id,
|
|
photo_url=f"/uploads/{unique_name}",
|
|
is_before=is_before,
|
|
)
|
|
db.add(photo)
|
|
created_photos.append(photo)
|
|
|
|
await db.flush()
|
|
for p in created_photos:
|
|
await db.refresh(p)
|
|
return created_photos
|
|
|
|
|
|
@router.get("/{ticket_id}/photos", response_model=list[TicketPhotoOut])
|
|
async def list_photos(
|
|
ticket_id: int,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
) -> list[TicketPhoto]:
|
|
"""List all photos for a ticket."""
|
|
await ticket_service.get_ticket(db, ticket_id)
|
|
result = await db.execute(
|
|
select(TicketPhoto)
|
|
.where(TicketPhoto.ticket_id == ticket_id)
|
|
.order_by(TicketPhoto.id)
|
|
)
|
|
return list(result.scalars().all())
|