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
+85
View File
@@ -15,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import hash_password
from app.models.unit import Unit
from app.models.user import User
from app.models.category import Category
logger = logging.getLogger(__name__)
@@ -118,3 +119,87 @@ async def seed_units(db: AsyncSession, json_path: str | Path | None = None) -> l
else:
logger.info("All units already exist")
return created
# ── Category taxonomy (PRD §7) ───────────────────────────────────────
# Top-level categories with sub-categories
SEED_CATEGORIES_DATA: list[dict] = [
# ── Maintenance ──────────────────────────────────────────────
{"type": "maintenance", "name": "Plumbing", "subs": ["WC not flushing", "Leaky tap", "Pipe burst", "Blocked drain", "Water heater"]},
{"type": "maintenance", "name": "Electrical", "subs": ["No power", "Tripped breaker", "Light fitting", "Socket repair", "Voltage issues"]},
{"type": "maintenance", "name": "HVAC", "subs": ["AC not cooling", "AC leaking", "Thermostat", "Fan not working"]},
{"type": "maintenance", "name": "Furniture", "subs": ["Broken bed", "Broken chair", "Broken table", "Wardrobe issue"]},
{"type": "maintenance", "name": "Appliances", "subs": ["Fridge", "Washing machine", "Microwave", "TV", "Kettle"]},
{"type": "maintenance", "name": "Cleaning", "subs": ["Deep clean requested", "Post-checkout turnover", "Common area cleaning"]},
{"type": "maintenance", "name": "Pest Control", "subs": ["Insects", "Rodents", "Fumigation"]},
{"type": "maintenance", "name": "Security", "subs": ["Lock broken", "Door not closing", "Window latch", "CCTV issue"]},
{"type": "maintenance", "name": "Internet", "subs": ["WiFi down", "Slow speed", "Router reset"]},
{"type": "maintenance", "name": "Structural", "subs": ["Wall crack", "Ceiling leak", "Floor tile", "Paint touch-up"]},
# ── Customer Service ─────────────────────────────────────────
{"type": "cs", "name": "Check-in", "subs": ["Early check-in", "Key handover", "Welcome instructions"]},
{"type": "cs", "name": "Check-out", "subs": ["Late checkout", "Key return", "Inspection"]},
{"type": "cs", "name": "Housekeeping", "subs": ["Mid-stay cleaning", "Linen change", "Restocking"]},
{"type": "cs", "name": "Lost Property", "subs": ["Guest left items behind"]},
{"type": "cs", "name": "Billing", "subs": ["Invoice question", "Payment issue", "Deposit query"]},
{"type": "cs", "name": "Staff Behaviour", "subs": ["Staff conduct feedback"]},
# ── Emergency ────────────────────────────────────────────────
{"type": "emergency", "name": "Fire", "subs": ["Smoke detected", "Fire alarm", "Sprinkler issue"], "sla_urgency": "urgent"},
{"type": "emergency", "name": "Flood", "subs": ["Major water leak", "Burst pipe", "Overflowing"], "sla_urgency": "urgent"},
{"type": "emergency", "name": "Gas Leak", "subs": ["Gas smell", "Suspected leak"], "sla_urgency": "urgent"},
{"type": "emergency", "name": "Electrical Hazard", "subs": ["Sparking", "Exposed wires", "Power outage multiple units"], "sla_urgency": "urgent"},
]
async def seed_categories(db: AsyncSession) -> list[Category]:
"""Seed the categories table with the full PRD §7 taxonomy."""
created: list[Category] = []
for group in SEED_CATEGORIES_DATA:
# Check if parent category exists
result = await db.execute(
select(Category).where(
Category.type == group["type"],
Category.name == group["name"],
Category.parent_id.is_(None),
)
)
parent = result.scalar_one_or_none()
if parent is None:
parent = Category(
type=group["type"],
name=group["name"],
parent_id=None,
sla_urgency=group.get("sla_urgency"),
)
db.add(parent)
await db.flush()
created.append(parent)
# Seed sub-categories
for sub_name in group["subs"]:
result = await db.execute(
select(Category).where(
Category.type == group["type"],
Category.name == sub_name,
Category.parent_id == parent.id,
)
)
existing = result.scalar_one_or_none()
if existing is None:
sub = Category(
type=group["type"],
name=sub_name,
parent_id=parent.id,
sla_urgency=None,
)
db.add(sub)
created.append(sub)
if created:
await db.flush()
for c in created:
await db.refresh(c)
logger.info("Seeded %d categories", len(created))
else:
logger.info("All seed categories already exist")
return created
+103
View File
@@ -0,0 +1,103 @@
"""SLA engine — priority-based deadlines, escalation triggers, and status checks."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.ticket import Ticket
# ── Priority SLA Targets (from PRD §11) ──────────────────────────────
# Each entry: (response_window_minutes, resolution_window_hours)
SLA_TARGETS: dict[str, tuple[int, int]] = {
"urgent": (15, 4), # respond 10-15min, resolve 2-4h → use upper bound
"high": (30, 24), # respond 30min, resolve 8-24h
"medium": (240, 72), # respond 4h (240min), resolve 2-3d (72h)
"low": (1440, 168), # respond 1d (1440min), resolve 5-7d (168h)
}
def compute_sla_deadline(priority: str, created_at: datetime | None = None) -> datetime:
"""Return the resolution deadline for a given priority.
Uses the upper-bound resolution target.
"""
now = created_at or datetime.now(timezone.utc)
_response_min, resolution_hours = SLA_TARGETS.get(priority, (240, 72))
return now + timedelta(hours=resolution_hours)
def get_sla_response_window(priority: str) -> timedelta:
"""Return the response-window timedelta for a given priority."""
minutes, _hours = SLA_TARGETS.get(priority, (240, 72))
return timedelta(minutes=minutes)
def get_sla_resolution_window(priority: str) -> timedelta:
"""Return the resolution-window timedelta for a given priority."""
_minutes, hours = SLA_TARGETS.get(priority, (240, 72))
return timedelta(hours=hours)
# ── Escalation detection ─────────────────────────────────────────────
def should_escalate_on_response(ticket: Ticket) -> bool:
"""Return True if the ticket's response SLA has been breached.
A ticket is in breach if its status is still in the pre-acknowledgement
states (New, Logged, Triage, Assigned) beyond the response window.
"""
if not ticket.priority or ticket.created_at is None:
return False
pre_ack_states = {"New", "Logged", "Triage", "Assigned"}
if ticket.status not in pre_ack_states:
return False
response_window = get_sla_response_window(ticket.priority)
deadline = ticket.created_at.replace(tzinfo=timezone.utc) + response_window
return datetime.now(timezone.utc) > deadline
def is_sla_breached(ticket: Ticket) -> bool:
"""Return True if the ticket's resolution SLA deadline has passed."""
if ticket.sla_deadline is None or ticket.status in ("Closed", "Completed"):
return False
deadline = ticket.sla_deadline
if deadline.tzinfo is None:
deadline = deadline.replace(tzinfo=timezone.utc)
return datetime.now(timezone.utc) > deadline
# ── API helper ───────────────────────────────────────────────────────
async def get_sla_status(ticket: Ticket) -> dict:
"""Return a structured SLA status dict for a ticket."""
now = datetime.now(timezone.utc)
created = ticket.created_at
if created and created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
response_deadline = None
resolution_deadline = ticket.sla_deadline
if ticket.priority and created:
response_deadline = created + get_sla_response_window(ticket.priority)
response_breached = False
if response_deadline and ticket.status in {"New", "Logged", "Triage", "Assigned"}:
response_breached = now > response_deadline
resolution_breached = False
if resolution_deadline:
rd = resolution_deadline
if rd.tzinfo is None:
rd = rd.replace(tzinfo=timezone.utc)
resolution_breached = now > rd if ticket.status not in ("Closed", "Completed") else False
return {
"priority": ticket.priority,
"response_deadline": response_deadline.isoformat() if response_deadline else None,
"resolution_deadline": resolution_deadline.isoformat() if resolution_deadline else None,
"response_breached": response_breached,
"resolution_breached": resolution_breached,
"current_status": ticket.status,
}
+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