Files
root 7fab1ede51 fix: address ask-user findings from review
- Move unit loading to use real /api/tickets/units endpoint
- Match unit code format to backend seed (0101E style)
- Send unit_id in create ticket payload
- Fix tech IDs to match seed order (9-15 for Prosper-Afful)
- Add customer_name and phone fields to TicketCreate schema
- Map form customer_name/phone into API payload
2026-07-23 19:08:49 +00:00

314 lines
12 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 app.core.config import settings
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.category import Category
from app.models.ticket import Ticket, TicketPhoto
from app.models.unit import Unit
from app.models.user import User
from app.schemas.ticket import (
CategoryOut,
CategoryTreeOut,
SLAStatusOut,
TicketBrief,
TicketCreate,
TicketListResponse,
TicketOut,
TicketPhotoOut,
TicketUpdate,
UnitOut,
)
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)
# ── Units ──────────────────────────────────────────────────────────
@router.get("/units", response_model=list[UnitOut])
async def list_units(
db: Annotated[AsyncSession, Depends(get_db)],
property_filter: str | None = Query(None, alias="property"),
) -> list[Unit]:
"""List all units, optionally filtered by property (East/West)."""
query = select(Unit).order_by(Unit.apartment_code)
if property_filter:
query = query.where(Unit.property == property_filter)
result = await db.execute(query)
return list(result.scalars().all())
# ── 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 ────────────────────────────────────────────────────
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
ALLOWED_MIME_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
@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)
# Validate all files before any write operations
for file in files:
if file.content_type not in ALLOWED_MIME_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported content type '{file.content_type}'. Allowed: image/jpeg, image/png, image/gif, image/webp",
)
ext = Path(file.filename or "photo.jpg").suffix.lower() if file.filename else ".jpg"
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported file extension '{ext}'. Allowed: .jpg, .jpeg, .png, .gif, .webp",
)
created_photos: list[TicketPhoto] = []
file_data: list[tuple[Path, bytes]] = []
for file in files:
# Generate unique filename
ext = Path(file.filename or "photo.jpg").suffix.lower() if file.filename else ".jpg"
unique_name = f"{uuid.uuid4().hex}{ext}"
file_path = UPLOADS_DIR / unique_name
content = await file.read()
file_data.append((file_path, content))
# Database record (no file write yet)
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)
# Write files to disk only after successful DB flush
for file_path, content in file_data:
file_path.write_bytes(content)
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())