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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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