Three client decisions for Denya OneCare / Pavilion Accra:
1. Technician roster converges to exactly 5 named techs
- Samuel Shang, Desmond Afful, Desmond Odekyi, Francis Norgbey, Nicholas Nartey
- New app/services/roster.py: converge_tech_roster() runs at startup and is
idempotent; off-roster techs are DEACTIVATED, never deleted, so ticket
history keeps a valid assignee reference
- seed.py SEED_USERS_DATA updated; placeholder emails until client confirms
2. Sub-contractors appear in the "Assign to" list alongside technicians
- New canonical role "Sub-contractor" (ASSIGNEE_POOL_ROLES = Tech + Sub-contractor)
- New GET /api/auth/assignees endpoint returns active pool members only
- Server-side _validate_assignee gate in ticket service rejects off-pool
or deactivated assignees (400/404)
- "Assign To" dropdown added to the new-ticket form; assigning at creation
auto-advances Logged -> Assigned
- base.html isTech() includes Sub-contractor (portal UX, tracked "under tech")
3. Penthouse units selectable when raising a ticket
- apartment_mapping.json: PH1E-/PH1W-/PH2E-/PH2W- -> clean codes
- seed_units self-heals legacy malformed codes on existing DBs and sets floors
- Penthouse units added to the built-in fallback seed
Tests: new tests/test_wahab_directives_20260928.py (8 tests); updated the
stale East unit count in test_categories_and_units.py (60 -> 62 with penthouses).
Full suite green.
564 lines
21 KiB
Python
564 lines
21 KiB
Python
"""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 delete as sa_delete, func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.core.roles import ASSIGNEE_POOL_ROLES
|
|
from app.models.ticket import Escalation, Ticket, TicketPhoto, TicketTimeline
|
|
from app.models.unit import Unit
|
|
from app.models.user import User
|
|
from app.services.sla import compute_sla_deadline
|
|
|
|
# ── Status Transition Map ────────────────────────────────────────────
|
|
# Keys: current status → list of valid next statuses
|
|
VALID_TRANSITIONS: dict[str, list[str]] = {
|
|
"New": ["Logged", "Cancelled"],
|
|
"Logged": ["Triage", "Closed", "Cancelled"],
|
|
"Triage": ["Assigned", "Escalated", "Cancelled"],
|
|
"Assigned": ["Accepted", "Triage", "Cancelled"],
|
|
"Accepted": ["Travelling", "Triage", "Cancelled"],
|
|
"Travelling": ["On Site", "Triage", "Cancelled"],
|
|
"On Site": ["In Progress", "Triage", "Cancelled"],
|
|
"In Progress": ["Waiting Parts", "Escalated", "Completed", "Cancelled"],
|
|
"Waiting Parts": ["In Progress", "Escalated", "Cancelled"],
|
|
"Escalated": ["Triage", "In Progress", "Completed", "Closed", "Cancelled"],
|
|
"Completed": ["On-Field Verification", "In Progress", "Cancelled"],
|
|
"On-Field Verification": ["Wahab Review", "Completed", "Closed", "Cancelled"],
|
|
"Wahab Review": ["Closed", "On-Field Verification", "Cancelled"],
|
|
"Closed": ["Reopened"],
|
|
"Reopened": ["Triage", "Logged", "Cancelled"],
|
|
# Terminal: cancelled tickets cannot resume work.
|
|
"Cancelled": [],
|
|
}
|
|
|
|
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.
|
|
|
|
Uses the highest existing suffix + 1 (not a row count) so that deleting
|
|
tickets never re-issues an already-used number.
|
|
"""
|
|
year = datetime.now(timezone.utc).year
|
|
prefix = f"PAV-{year}-"
|
|
result = await db.execute(
|
|
select(func.max(Ticket.ticket_number)).where(Ticket.ticket_number.like(f"{prefix}%"))
|
|
)
|
|
max_number = result.scalar()
|
|
if max_number:
|
|
try:
|
|
next_seq = int(max_number.rsplit("-", 1)[1]) + 1
|
|
except (ValueError, IndexError):
|
|
next_seq = 1
|
|
else:
|
|
next_seq = 1
|
|
return f"{prefix}{next_seq: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 _validate_assignee(db: AsyncSession, assigned_to: int | None) -> None:
|
|
"""Assignment must land on an active member of the pool (Tech roster +
|
|
sub-contractors, client directive 2026-09-28). Deactivated accounts —
|
|
replaced technicians included — can no longer receive new work."""
|
|
if assigned_to is None:
|
|
return
|
|
user = (
|
|
await db.execute(select(User).where(User.id == assigned_to))
|
|
).scalar_one_or_none()
|
|
if user is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Assignee not found")
|
|
if not user.active or user.role not in ASSIGNEE_POOL_ROLES:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"{user.full_name} is not in the assignable pool "
|
|
f"({'/'.join(ASSIGNEE_POOL_ROLES)}, active only)",
|
|
)
|
|
|
|
|
|
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
|
|
# Original report date: backdated/backfilled tickets keep their true date;
|
|
# when omitted the ticket is considered reported right now. The SLA clock
|
|
# is unchanged — deadlines run from creation time, not the reported date.
|
|
reported_at = data.get("reported_at") or datetime.now(timezone.utc)
|
|
await _validate_assignee(db, data.get("assigned_to"))
|
|
|
|
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") or data.get("customer_name"),
|
|
phone=data.get("phone"),
|
|
reported_via=data.get("reported_via"),
|
|
description=data.get("description"),
|
|
assigned_to=data.get("assigned_to"),
|
|
reported_at=reported_at,
|
|
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,
|
|
)
|
|
|
|
# Assigning at creation advances Logged → Assigned (same rule as PATCH
|
|
# /tickets/{id} — client directive 2026-09-28 added the "Assign to"
|
|
# picker on the new-ticket form).
|
|
if ticket.assigned_to:
|
|
ticket.status = "Assigned"
|
|
await _log_status_change(
|
|
db,
|
|
ticket.id,
|
|
from_status="Logged",
|
|
to_status="Assigned",
|
|
note="Assigned at creation",
|
|
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,
|
|
building_filter: str | None = None,
|
|
unit_id: int | 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(Unit.property == property_filter)
|
|
count_query = count_query.join(Ticket.unit).where(Unit.property == property_filter)
|
|
if building_filter:
|
|
query = query.join(Ticket.unit).where(Unit.building == building_filter)
|
|
count_query = count_query.join(Ticket.unit).where(Unit.building == building_filter)
|
|
if unit_id:
|
|
query = query.where(Ticket.unit_id == unit_id)
|
|
count_query = count_query.where(Ticket.unit_id == unit_id)
|
|
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(), Ticket.id.desc()).offset(offset).limit(page_size).options(selectinload(Ticket.assigned_technician))
|
|
|
|
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)
|
|
await _validate_assignee(db, data.get("assigned_to"))
|
|
|
|
# Handle status transitions separately
|
|
new_status = data.get("status")
|
|
old_status = ticket.status
|
|
status_changed = new_status is not None and old_status != new_status
|
|
if new_status is not None:
|
|
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 += 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"),
|
|
user_id=user.id if user else None,
|
|
)
|
|
|
|
ticket.status = new_status
|
|
|
|
# Assigning a technician advances pre-Assigned tickets to Assigned with a
|
|
# timeline entry; tickets already past Assigned keep their current status.
|
|
advanced_to_assigned = False
|
|
if "assigned_to" in data and data["assigned_to"] != ticket.assigned_to:
|
|
if ticket.status in {"New", "Logged", "Triage"}:
|
|
await _log_status_change(
|
|
db,
|
|
ticket.id,
|
|
from_status=ticket.status,
|
|
to_status="Assigned",
|
|
note=data.get("note") if not status_changed else None,
|
|
user_id=user.id if user else None,
|
|
)
|
|
ticket.status = "Assigned"
|
|
advanced_to_assigned = True
|
|
|
|
# Handle standalone note (no status change)
|
|
note_only = data.get("note")
|
|
if note_only and not status_changed and not advanced_to_assigned:
|
|
await _log_status_change(
|
|
db,
|
|
ticket.id,
|
|
from_status=ticket.status,
|
|
to_status=ticket.status,
|
|
note=note_only,
|
|
user_id=user.id if user else None,
|
|
)
|
|
|
|
# Update other fields
|
|
for field in ("unit_id", "category_id", "priority", "reporter", "phone", "reported_via",
|
|
"description", "assigned_to", "eta", "cost", "parts_used"):
|
|
if field in data:
|
|
setattr(ticket, field, data[field])
|
|
|
|
# Recompute SLA if priority changed
|
|
if data.get("priority"):
|
|
ticket.sla_deadline = compute_sla_deadline(data["priority"])
|
|
|
|
await db.flush()
|
|
await db.refresh(ticket)
|
|
return ticket
|
|
|
|
|
|
# ── Technician performance ───────────────────────────────────────────
|
|
# Buckets for the technician-performance dashboard (Wahab request). The map is
|
|
# intentionally not exhaustive: any status missing from it is counted as
|
|
# "pending" so the named buckets always sum to ``total_assigned`` and no
|
|
# assigned ticket is silently dropped from the report.
|
|
_TECH_STATUS_BUCKETS: dict[str, str] = {
|
|
"Completed": "completed",
|
|
"Closed": "completed",
|
|
"Accepted": "in_progress",
|
|
"Travelling": "in_progress",
|
|
"On Site": "in_progress",
|
|
"In Progress": "in_progress",
|
|
"Waiting Parts": "in_progress",
|
|
"Escalated": "escalated",
|
|
"Cancelled": "cancelled",
|
|
}
|
|
|
|
|
|
def _bucket_for_status(status: str) -> str:
|
|
"""Map a ticket status onto its technician-performance bucket."""
|
|
return _TECH_STATUS_BUCKETS.get(status, "pending")
|
|
|
|
|
|
def _empty_tech_entry(technician_id: int, name: str) -> dict[str, Any]:
|
|
return {
|
|
"technician_id": technician_id,
|
|
"name": name,
|
|
"total_assigned": 0,
|
|
"completed": 0,
|
|
"closed": 0,
|
|
"in_progress": 0,
|
|
"escalated": 0,
|
|
"cancelled": 0,
|
|
"pending": 0,
|
|
"open_tickets": 0,
|
|
"completion_rate": 0.0,
|
|
"avg_resolution_hours": None,
|
|
"resolved_with_timestamps": 0,
|
|
"resolved_without_timestamps": 0,
|
|
"status_breakdown": {},
|
|
"_hours_sum": 0.0,
|
|
}
|
|
|
|
|
|
async def get_technician_performance(db: AsyncSession) -> dict[str, Any]:
|
|
"""Aggregate per-technician workload/outcome stats for the dashboard.
|
|
|
|
Derived entirely from existing ``tickets`` columns (``assigned_to``,
|
|
``status``, ``created_at``, ``closed_at``) joined to ``users.full_name`` —
|
|
no schema change. Technician identity is keyed on the user id so two people
|
|
sharing a display name stay separate rows, while the reported label is the
|
|
real name (never ``"Tech #<id>"``).
|
|
|
|
Completion rate is ``completed / total_assigned`` (Completed + Closed count
|
|
as completed). Resolution time averages ``created_at -> closed_at`` only for
|
|
rows where ``closed_at`` is set; the number of finished tasks lacking that
|
|
timestamp is reported separately so an absent average is never mistaken for
|
|
missing work.
|
|
"""
|
|
rows = (
|
|
await db.execute(
|
|
select(
|
|
Ticket.assigned_to,
|
|
User.full_name,
|
|
Ticket.status,
|
|
Ticket.created_at,
|
|
Ticket.closed_at,
|
|
)
|
|
.join(User, User.id == Ticket.assigned_to)
|
|
.where(Ticket.assigned_to.is_not(None))
|
|
)
|
|
).all()
|
|
|
|
unassigned_result = await db.execute(
|
|
select(func.count(Ticket.id)).where(Ticket.assigned_to.is_(None))
|
|
)
|
|
unassigned_tickets = unassigned_result.scalar() or 0
|
|
|
|
by_tech: dict[int, dict[str, Any]] = {}
|
|
for assigned_to, full_name, status, created_at, closed_at in rows:
|
|
entry = by_tech.get(assigned_to)
|
|
if entry is None:
|
|
entry = _empty_tech_entry(assigned_to, full_name)
|
|
by_tech[assigned_to] = entry
|
|
|
|
entry["total_assigned"] += 1
|
|
bucket = _bucket_for_status(status)
|
|
if bucket == "completed":
|
|
entry["completed"] += 1
|
|
if status == "Closed":
|
|
entry["closed"] += 1
|
|
if closed_at is not None and created_at is not None:
|
|
hours = (closed_at - created_at).total_seconds() / 3600
|
|
entry["_hours_sum"] += hours
|
|
entry["resolved_with_timestamps"] += 1
|
|
else:
|
|
entry["resolved_without_timestamps"] += 1
|
|
else:
|
|
entry[bucket] += 1
|
|
|
|
breakdown = entry["status_breakdown"]
|
|
breakdown[status] = breakdown.get(status, 0) + 1
|
|
|
|
technicians: list[dict[str, Any]] = []
|
|
total_assigned = total_completed = total_closed = 0
|
|
total_in_progress = total_escalated = total_cancelled = total_pending = 0
|
|
total_hours = 0.0
|
|
total_with_timestamps = total_without_timestamps = 0
|
|
|
|
for entry in by_tech.values():
|
|
assigned = entry["total_assigned"]
|
|
entry["open_tickets"] = assigned - entry["completed"] - entry["cancelled"]
|
|
entry["completion_rate"] = round(entry["completed"] / assigned * 100, 1) if assigned else 0.0
|
|
if entry["resolved_with_timestamps"]:
|
|
entry["avg_resolution_hours"] = round(
|
|
entry["_hours_sum"] / entry["resolved_with_timestamps"], 1
|
|
)
|
|
entry["status_breakdown"] = dict(
|
|
sorted(entry["status_breakdown"].items(), key=lambda kv: (-kv[1], kv[0]))
|
|
)
|
|
|
|
total_assigned += assigned
|
|
total_completed += entry["completed"]
|
|
total_closed += entry["closed"]
|
|
total_in_progress += entry["in_progress"]
|
|
total_escalated += entry["escalated"]
|
|
total_cancelled += entry["cancelled"]
|
|
total_pending += entry["pending"]
|
|
total_hours += entry["_hours_sum"]
|
|
total_with_timestamps += entry["resolved_with_timestamps"]
|
|
total_without_timestamps += entry["resolved_without_timestamps"]
|
|
|
|
del entry["_hours_sum"]
|
|
technicians.append(entry)
|
|
|
|
# Busiest/most productive first; ties broken by workload then real name.
|
|
technicians.sort(key=lambda e: (-e["completed"], -e["total_assigned"], e["name"].lower()))
|
|
|
|
totals = {
|
|
"technicians": len(technicians),
|
|
"total_assigned": total_assigned,
|
|
"completed": total_completed,
|
|
"closed": total_closed,
|
|
"in_progress": total_in_progress,
|
|
"escalated": total_escalated,
|
|
"cancelled": total_cancelled,
|
|
"pending": total_pending,
|
|
"open_tickets": total_assigned - total_completed - total_cancelled,
|
|
"completion_rate": round(total_completed / total_assigned * 100, 1) if total_assigned else 0.0,
|
|
"avg_resolution_hours": round(total_hours / total_with_timestamps, 1) if total_with_timestamps else None,
|
|
"resolved_with_timestamps": total_with_timestamps,
|
|
"resolved_without_timestamps": total_without_timestamps,
|
|
"unassigned_tickets": unassigned_tickets,
|
|
"total_tickets": total_assigned + unassigned_tickets,
|
|
}
|
|
|
|
return {
|
|
"generated_at": datetime.now(timezone.utc),
|
|
"technicians": technicians,
|
|
"totals": totals,
|
|
}
|
|
|
|
|
|
async def delete_ticket(db: AsyncSession, ticket_id: int) -> Ticket:
|
|
"""Delete a ticket and all dependent rows (timeline, photos, escalations).
|
|
|
|
Returns the deleted ticket so the caller can remove orphaned photo files.
|
|
"""
|
|
ticket = await _get_ticket_or_404(db, ticket_id)
|
|
|
|
# Delete escalations, timeline, and photo rows first (FK children).
|
|
await db.execute(sa_delete(Escalation).where(Escalation.ticket_id == ticket_id))
|
|
await db.execute(sa_delete(TicketTimeline).where(TicketTimeline.ticket_id == ticket_id))
|
|
await db.execute(sa_delete(TicketPhoto).where(TicketPhoto.ticket_id == ticket_id))
|
|
await db.delete(ticket)
|
|
await db.flush()
|
|
return ticket
|
|
|
|
|