Sprint 2: Ticket Engine + SLA + Photo Uploads

- 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
This commit is contained in:
root
2026-07-23 17:13:12 +00:00
parent 06bcdb0392
commit 65622de7a6
8 changed files with 936 additions and 6 deletions
+299
View File
@@ -0,0 +1,299 @@
"""Ticket business logic — CRUD, status transitions, SLA enforcement."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.ticket import Escalation, Ticket, TicketPhoto, TicketTimeline
from app.models.user import User
from app.services.sla import compute_sla_deadline, get_sla_status
# ── Status Transition Map ────────────────────────────────────────────
# Keys: current status → list of valid next statuses
VALID_TRANSITIONS: dict[str, list[str]] = {
"New": ["Logged"],
"Logged": ["Triage", "Closed"],
"Triage": ["Assigned", "Escalated"],
"Assigned": ["Accepted", "Triage"],
"Accepted": ["Travelling", "Triage"],
"Travelling": ["On Site", "Triage"],
"On Site": ["In Progress", "Triage"],
"In Progress": ["Waiting Parts", "Escalated", "Completed"],
"Waiting Parts": ["In Progress", "Escalated"],
"Escalated": ["Triage", "In Progress", "Completed", "Closed"],
"Completed": ["On-Field Verification", "In Progress"],
"On-Field Verification": ["Wahab Review", "Completed", "Closed"],
"Wahab Review": ["Closed", "On-Field Verification"],
"Closed": ["Reopened"],
"Reopened": ["Triage", "Logged"],
}
REOPEN_WINDOW_DAYS = 7
SLA_ACK_USER = "Ama"
# ── Helpers ──────────────────────────────────────────────────────────
async def _generate_ticket_number(db: AsyncSession) -> str:
"""Generate the next ticket number in PAV-YYYY-NNNNN format."""
year = datetime.now(timezone.utc).year
prefix = f"PAV-{year}-"
# Count existing tickets this year
result = await db.execute(
select(func.count(Ticket.id)).where(Ticket.ticket_number.like(f"{prefix}%"))
)
count = result.scalar() or 0
return f"{prefix}{count + 1:05d}"
async def _log_status_change(
db: AsyncSession,
ticket_id: int,
from_status: str | None,
to_status: str | None,
note: str | None = None,
user_id: int | None = None,
) -> TicketTimeline:
"""Create a timeline entry for a status change."""
entry = TicketTimeline(
ticket_id=ticket_id,
from_status=from_status,
to_status=to_status,
note=note,
user_id=user_id,
)
db.add(entry)
await db.flush()
return entry
async def _get_ticket_or_404(db: AsyncSession, ticket_id: int) -> Ticket:
"""Fetch a ticket by ID or raise 404."""
result = await db.execute(
select(Ticket)
.options(
selectinload(Ticket.timeline),
selectinload(Ticket.photos),
selectinload(Ticket.assigned_technician),
selectinload(Ticket.unit),
selectinload(Ticket.category),
)
.where(Ticket.id == ticket_id)
)
ticket = result.scalar_one_or_none()
if ticket is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ticket not found")
return ticket
# ── CRUD ─────────────────────────────────────────────────────────────
async def create_ticket(
db: AsyncSession,
data: dict[str, Any],
user: User | None = None,
) -> Ticket:
"""Create a new ticket with auto-numbering and SLA deadline."""
ticket_number = await _generate_ticket_number(db)
priority = data.get("priority")
sla_deadline = compute_sla_deadline(priority) if priority else None
ticket = Ticket(
ticket_number=ticket_number,
status="New",
unit_id=data.get("unit_id"),
category_id=data.get("category_id"),
priority=priority,
reporter=data.get("reporter"),
reported_via=data.get("reported_via"),
description=data.get("description"),
assigned_to=data.get("assigned_to"),
sla_deadline=sla_deadline,
)
db.add(ticket)
await db.flush()
await db.refresh(ticket)
# Log initial creation
await _log_status_change(
db,
ticket.id,
from_status=None,
to_status="New",
note="Ticket created",
user_id=user.id if user else None,
)
# Auto-advance New → Logged (creation is also the logging step)
ticket.status = "Logged"
await _log_status_change(
db,
ticket.id,
from_status="New",
to_status="Logged",
note="Ticket logged and numbered",
user_id=user.id if user else None,
)
await db.flush()
await db.refresh(ticket)
return ticket
async def get_ticket(db: AsyncSession, ticket_id: int) -> Ticket:
"""Get a single ticket with full details."""
return await _get_ticket_or_404(db, ticket_id)
async def list_tickets(
db: AsyncSession,
*,
status_filter: str | None = None,
priority_filter: str | None = None,
property_filter: str | None = None,
category_id: int | None = None,
assigned_to: int | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
page: int = 1,
page_size: int = 50,
) -> tuple[list[Ticket], int]:
"""List tickets with optional filters. Returns (tickets, total_count)."""
query = select(Ticket)
count_query = select(func.count(Ticket.id))
if status_filter:
query = query.where(Ticket.status == status_filter)
count_query = count_query.where(Ticket.status == status_filter)
if priority_filter:
query = query.where(Ticket.priority == priority_filter)
count_query = count_query.where(Ticket.priority == priority_filter)
if property_filter:
# Join unit to filter by property
query = query.join(Ticket.unit).where(Ticket.unit.has(property=property_filter))
count_query = count_query.join(Ticket.unit).where(Ticket.unit.has(property=property_filter))
if category_id:
query = query.where(Ticket.category_id == category_id)
count_query = count_query.where(Ticket.category_id == category_id)
if assigned_to:
query = query.where(Ticket.assigned_to == assigned_to)
count_query = count_query.where(Ticket.assigned_to == assigned_to)
if date_from:
query = query.where(Ticket.created_at >= date_from)
count_query = count_query.where(Ticket.created_at >= date_from)
if date_to:
query = query.where(Ticket.created_at <= date_to)
count_query = count_query.where(Ticket.created_at <= date_to)
# Count total
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Paginate
offset = (page - 1) * page_size
query = query.order_by(Ticket.created_at.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
tickets = list(result.scalars().all())
return tickets, total
async def update_ticket(
db: AsyncSession,
ticket_id: int,
data: dict[str, Any],
user: User | None = None,
) -> Ticket:
"""Update a ticket. Status changes are validated and logged."""
ticket = await _get_ticket_or_404(db, ticket_id)
# Handle status transitions separately
new_status = data.get("status")
if new_status is not None:
old_status = ticket.status
if old_status != new_status:
valid_targets = VALID_TRANSITIONS.get(old_status, [])
if new_status not in valid_targets:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid status transition: {old_status}{new_status}. "
f"Valid targets: {valid_targets}",
)
# Handle special logic for reopening
if new_status == "Reopened":
if old_status != "Closed":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Can only reopen a Closed ticket",
)
if ticket.closed_at:
closed_at = ticket.closed_at
if closed_at.tzinfo is None:
closed_at = closed_at.replace(tzinfo=timezone.utc)
days_since_close = (datetime.now(timezone.utc) - closed_at).days
if days_since_close > REOPEN_WINDOW_DAYS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot reopen: ticket closed {days_since_close} days ago "
f"(max {REOPEN_WINDOW_DAYS} days)",
)
# Auto-escalate to Ama
ama_result = await db.execute(
select(User).where(User.full_name == SLA_ACK_USER)
)
ama_user = ama_result.scalar_one_or_none()
ticket.reopen_count = (ticket.reopen_count or 0) + 1
ticket.closed_at = None
if ama_user:
escalation = Escalation(
ticket_id=ticket.id,
escalated_to=ama_user.id,
reason=f"Auto-escalation: ticket reopened (reopen #{ticket.reopen_count})",
)
db.add(escalation)
# Handle closing
if new_status == "Closed":
ticket.closed_at = datetime.now(timezone.utc)
# Log the transition
await _log_status_change(
db,
ticket.id,
from_status=old_status,
to_status=new_status,
note=data.get("note") or data.get("note"),
user_id=user.id if user else None,
)
ticket.status = new_status
# Recompute SLA if priority changed
if data.get("priority") and data["priority"] != ticket.priority:
ticket.sla_deadline = compute_sla_deadline(data["priority"])
# Update other fields
for field in ("unit_id", "category_id", "priority", "reporter", "reported_via",
"description", "assigned_to", "eta", "cost", "parts_used"):
if field in data:
setattr(ticket, field, data[field])
# Recompute SLA if priority changed and status didn't change
if "priority" in data and not new_status:
ticket.sla_deadline = compute_sla_deadline(data["priority"])
await db.flush()
await db.refresh(ticket)
return ticket
async def get_ticket_with_sla(db: AsyncSession, ticket_id: int) -> Ticket:
"""Get a ticket and attach SLA status to it."""
ticket = await get_ticket(db, ticket_id)
return ticket