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