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:
@@ -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
|
||||
Reference in New Issue
Block a user