Sprint 2: Ticket Engine, SLA Engine, Photo Uploads, Category System #2

Merged
abiba-bot merged 7 commits from fm/denya-onecare-s2 into main 2026-07-23 18:55:00 +00:00
8 changed files with 936 additions and 6 deletions
Showing only changes of commit 65622de7a6 - Show all commits
+2
View File
@@ -4,6 +4,8 @@ __pycache__/
*.sqlite3 *.sqlite3
.env .env
.venv/ .venv/
uploads/
test_*.py
venv/ venv/
*.egg-info/ *.egg-info/
dist/ dist/
+41 -2
View File
@@ -19,9 +19,10 @@ app/
├── core/ # config, database, security (JWT + bcrypt + RBAC) ├── core/ # config, database, security (JWT + bcrypt + RBAC)
├── models/ # SQLAlchemy ORM models ├── models/ # SQLAlchemy ORM models
├── schemas/ # Pydantic request/response schemas ├── schemas/ # Pydantic request/response schemas
├── services/ # Business logic (auth, seed) ├── services/ # Business logic (auth, seed, ticket, sla)
└── routers/ # FastAPI route handlers └── routers/ # FastAPI route handlers
alembic/ # Database migrations alembic/ # Database migrations
uploads/ # Photo uploads (created at runtime)
``` ```
## Commands ## Commands
@@ -31,14 +32,16 @@ alembic/ # Database migrations
## Seed data ## Seed data
Users and units are auto-seeded on first startup via lifespan hook: Users, units, and categories are auto-seeded on first startup via lifespan hook:
- 17 users covering all roles (Admin/Jerome, Admin/Wahab, CS Rep, CS Manager, FM Dispatcher, Tech, CEO, Director) - 17 users covering all roles (Admin/Jerome, Admin/Wahab, CS Rep, CS Manager, FM Dispatcher, Tech, CEO, Director)
- 120 apartment units (East/West, 10 floors × 6 apts per wing) - 120 apartment units (East/West, 10 floors × 6 apts per wing)
- Default password for all seed users: `denya123` - Default password for all seed users: `denya123`
- Units load from `apartment_mapping.json` if present, else built-in fallback - Units load from `apartment_mapping.json` if present, else built-in fallback
- Categories: 20 top-level (10 Maintenance, 6 CS, 4 Emergency) with sub-categories, seeded from `app/services/seed.py::SEED_CATEGORIES_DATA`
## Key API endpoints ## Key API endpoints
### Auth & Health
| Method | Path | Auth | Description | | Method | Path | Auth | Description |
|--------|------|------|-------------| |--------|------|------|-------------|
| GET | `/health` | No | Health check | | GET | `/health` | No | Health check |
@@ -50,6 +53,20 @@ Users and units are auto-seeded on first startup via lifespan hook:
| POST | `/api/whatsapp/mock` | No | Mock WhatsApp | | POST | `/api/whatsapp/mock` | No | Mock WhatsApp |
| GET | `/api/whatsapp/mock-log` | No | Recent mock submissions | | GET | `/api/whatsapp/mock-log` | No | Recent mock submissions |
### Tickets (Sprint 2)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/tickets` | Bearer | Create ticket (auto-number PAV-YYYY-NNNNN) |
| GET | `/api/tickets` | No | List tickets (filter: status, priority, property, category_id, assigned_to, date_from, date_to) |
| GET | `/api/tickets/{id}` | No | Get ticket detail with timeline, photos, SLA status |
| PATCH | `/api/tickets/{id}` | Bearer | Update ticket (validates status transitions) |
| POST | `/api/tickets/{id}/status` | Bearer | Change status with note |
| GET | `/api/tickets/{id}/sla` | No | Check SLA breach status |
| POST | `/api/tickets/{id}/photos` | Bearer | Upload photos (multipart, is_before param) |
| GET | `/api/tickets/{id}/photos` | No | List photos |
| GET | `/api/tickets/categories` | No | Category tree (optional `?type=` filter) |
| GET | `/api/tickets/categories/flat` | No | Flat category list |
## Auth ## Auth
- JWT access (30min) + refresh (7d) tokens - JWT access (30min) + refresh (7d) tokens
@@ -57,6 +74,28 @@ Users and units are auto-seeded on first startup via lifespan hook:
- Use `require_roles("Admin/Jerome", "Admin/Wahab")` dependency for RBAC - Use `require_roles("Admin/Jerome", "Admin/Wahab")` dependency for RBAC
- `sub` claim holds string user ID - `sub` claim holds string user ID
## Ticket System (Sprint 2)
### Status Lifecycle (15 statuses)
New → Logged → Triage → Assigned → Accepted → Travelling → On Site → In Progress → Waiting Parts → Escalated → Completed → On-Field Verification → Wahab Review → Closed → Reopened
Valid transitions defined in `app/services/ticket.py::VALID_TRANSITIONS`. Invalid transitions return 400.
### SLA Engine
Defined in `app/services/sla.py`. Priority-based targets:
- Urgent: respond 15min, resolve 4h
- High: respond 30min, resolve 24h
- Medium: respond 4h, resolve 72h (3d)
- Low: respond 24h, resolve 168h (7d)
`sla_deadline` auto-calculated on ticket creation. SLA status check at `GET /api/tickets/{id}/sla`.
### Ticket Number Format
`PAV-YYYY-NNNNN` — sequential per year (e.g., PAV-2026-00001).
### Photo Uploads
Stored under `uploads/` with UUID filenames. Static-files mounted at `/uploads/`. Multipart POST with `is_before` query param.
## Database ## Database
SQLite via aiosqlite with async SQLAlchemy 2.0. Alembic for migrations. SQLite via aiosqlite with async SQLAlchemy 2.0. Alembic for migrations.
+12 -4
View File
@@ -4,16 +4,16 @@ from __future__ import annotations
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text from fastapi.staticfiles import StaticFiles
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings from app.core.config import settings
from app.core.database import Base, async_session_factory, engine from app.core.database import Base, async_session_factory, engine
from app.routers import auth, health, whatsapp from app.routers import auth, health, tickets, whatsapp
from app.services.seed import seed_units, seed_users from app.services.seed import seed_categories, seed_units, seed_users
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -29,6 +29,8 @@ async def lifespan(app: FastAPI):
await session.commit() await session.commit()
await seed_units(session, json_path=str(settings.BASE_DIR / "apartment_mapping.json")) await seed_units(session, json_path=str(settings.BASE_DIR / "apartment_mapping.json"))
await session.commit() await session.commit()
await seed_categories(session)
await session.commit()
yield yield
await engine.dispose() await engine.dispose()
logger.info("Denya OneCare stopped.") logger.info("Denya OneCare stopped.")
@@ -49,7 +51,13 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# ── Static files (uploads) ───────────────────────────────────────────
uploads_dir = Path(settings.BASE_DIR / "uploads")
uploads_dir.mkdir(parents=True, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=str(uploads_dir)), name="uploads")
# ── Routers ────────────────────────────────────────────────────────── # ── Routers ──────────────────────────────────────────────────────────
app.include_router(health.router) app.include_router(health.router)
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(whatsapp.router) app.include_router(whatsapp.router)
app.include_router(tickets.router)
+277
View File
@@ -0,0 +1,277 @@
"""Ticket CRUD endpoints, photo uploads, SLA checks, and category listing."""
from __future__ import annotations
import os
import uuid
from datetime import datetime
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.config import settings
from app.core.database import get_db
from app.core.security import get_current_user, require_roles
from app.models.category import Category
from app.models.ticket import Ticket, TicketPhoto
from app.models.user import User
from app.schemas.ticket import (
CategoryOut,
CategoryTreeOut,
SLAStatusOut,
TicketBrief,
TicketCreate,
TicketListResponse,
TicketOut,
TicketPhotoOut,
TicketTimelineOut,
TicketUpdate,
)
from app.services import ticket as ticket_service
from app.services.sla import get_sla_status
router = APIRouter(prefix="/api/tickets", tags=["tickets"])
# Ensure uploads directory exists
UPLOADS_DIR = settings.BASE_DIR / "uploads"
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
# ── Categories ──────────────────────────────────────────────────────
async def _build_category_tree(db: AsyncSession, parent_id: int | None = None) -> list[CategoryTreeOut]:
"""Build a nested category tree."""
result = await db.execute(
select(Category)
.where(Category.parent_id == parent_id)
.order_by(Category.name)
)
categories = result.scalars().all()
tree = []
for cat in categories:
children = await _build_category_tree(db, cat.id)
tree.append(CategoryTreeOut(
id=cat.id,
type=cat.type,
name=cat.name,
parent_id=cat.parent_id,
sla_urgency=cat.sla_urgency,
children=children,
))
return tree
@router.get("/categories", response_model=list[CategoryTreeOut])
async def list_categories(
db: Annotated[AsyncSession, Depends(get_db)],
type_filter: str | None = Query(None, alias="type"),
) -> list[CategoryTreeOut]:
"""Return the full category tree, optionally filtered by type (maintenance, cs, emergency)."""
if type_filter:
# Return flat list of top-level categories of the given type
result = await db.execute(
select(Category)
.where(Category.parent_id.is_(None), Category.type == type_filter)
.order_by(Category.name)
)
parents = result.scalars().all()
tree = []
for parent in parents:
children_result = await db.execute(
select(Category)
.where(Category.parent_id == parent.id)
.order_by(Category.name)
)
children = children_result.scalars().all()
tree.append(CategoryTreeOut(
id=parent.id,
type=parent.type,
name=parent.name,
parent_id=parent.parent_id,
sla_urgency=parent.sla_urgency,
children=[CategoryOut.model_validate(c) for c in children],
))
return tree
return await _build_category_tree(db)
@router.get("/categories/flat", response_model=list[CategoryOut])
async def list_categories_flat(
db: Annotated[AsyncSession, Depends(get_db)],
type_filter: str | None = Query(None, alias="type"),
) -> list[CategoryOut]:
"""Return a flat list of all categories (no nesting), optionally filtered by type."""
query = select(Category).order_by(Category.type, Category.name)
if type_filter:
query = query.where(Category.type == type_filter)
result = await db.execute(query)
categories = result.scalars().all()
return [CategoryOut.model_validate(c) for c in categories]
# ── Ticket CRUD ──────────────────────────────────────────────────────
@router.post("", response_model=TicketOut, status_code=status.HTTP_201_CREATED)
async def create_ticket(
body: TicketCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> Ticket:
"""Create a new ticket. Auto-generates ticket number PAV-YYYY-NNNNN."""
ticket = await ticket_service.create_ticket(db, body.model_dump(exclude_none=True), user=current_user)
# Reload with relationships
ticket = await ticket_service.get_ticket(db, ticket.id)
sla = await get_sla_status(ticket)
ticket.sla_status = sla # type: ignore[attr-defined]
return ticket
@router.get("", response_model=TicketListResponse)
async def list_tickets(
db: Annotated[AsyncSession, Depends(get_db)],
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
status: str | None = Query(None),
priority: str | None = Query(None),
property: str | None = Query(None),
category_id: int | None = Query(None),
assigned_to: int | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
) -> TicketListResponse:
"""List tickets with optional filtering and pagination."""
tickets, total = await ticket_service.list_tickets(
db,
status_filter=status,
priority_filter=priority,
property_filter=property,
category_id=category_id,
assigned_to=assigned_to,
date_from=date_from,
date_to=date_to,
page=page,
page_size=page_size,
)
items = [TicketBrief.model_validate(t) for t in tickets]
return TicketListResponse(items=items, total=total, page=page, page_size=page_size)
@router.get("/{ticket_id}", response_model=TicketOut)
async def get_ticket(
ticket_id: int,
db: Annotated[AsyncSession, Depends(get_db)],
) -> Ticket:
"""Get a single ticket by ID with full details, timeline, photos, and SLA status."""
ticket = await ticket_service.get_ticket(db, ticket_id)
sla = await get_sla_status(ticket)
ticket.sla_status = sla # type: ignore[attr-defined]
return ticket
@router.patch("/{ticket_id}", response_model=TicketOut)
async def update_ticket(
ticket_id: int,
body: TicketUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> Ticket:
"""Update a ticket. Validates status transitions and logs timeline."""
ticket = await ticket_service.update_ticket(db, ticket_id, body.model_dump(exclude_none=True), user=current_user)
sla = await get_sla_status(ticket)
ticket.sla_status = sla # type: ignore[attr-defined]
return ticket
# ── Status Transitions (convenience endpoints) ───────────────────────
@router.post("/{ticket_id}/status", response_model=TicketOut)
async def change_ticket_status(
ticket_id: int,
body: dict,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> Ticket:
"""Change a ticket's status with optional note.
Request body:
```json
{"status": "Logged", "note": "Verified with customer"}
```
"""
status_val = body.get("status")
if not status_val:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="status is required")
update_data = {"status": status_val}
if "note" in body:
update_data["note"] = body["note"]
ticket = await ticket_service.update_ticket(db, ticket_id, update_data, user=current_user)
sla = await get_sla_status(ticket)
ticket.sla_status = sla # type: ignore[attr-defined]
return ticket
# ── SLA ──────────────────────────────────────────────────────────────
@router.get("/{ticket_id}/sla", response_model=SLAStatusOut)
async def check_sla(
ticket_id: int,
db: Annotated[AsyncSession, Depends(get_db)],
) -> dict:
"""Check SLA status for a ticket — response deadline, resolution deadline, breach flags."""
ticket = await ticket_service.get_ticket(db, ticket_id)
return await get_sla_status(ticket)
# ── Photo Uploads ────────────────────────────────────────────────────
@router.post("/{ticket_id}/photos", response_model=list[TicketPhotoOut], status_code=status.HTTP_201_CREATED)
async def upload_photos(
ticket_id: int,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
files: list[UploadFile],
is_before: bool = True,
) -> list[TicketPhoto]:
"""Upload photos for a ticket (before/after). Stores files under uploads/."""
# Verify ticket exists
await ticket_service.get_ticket(db, ticket_id)
created_photos: list[TicketPhoto] = []
for file in files:
# Generate unique filename
ext = Path(file.filename or "photo.jpg").suffix if file.filename else ".jpg"
unique_name = f"{uuid.uuid4().hex}{ext}"
file_path = UPLOADS_DIR / unique_name
# Write file
content = await file.read()
file_path.write_bytes(content)
# Database record
photo = TicketPhoto(
ticket_id=ticket_id,
photo_url=f"/uploads/{unique_name}",
is_before=is_before,
)
db.add(photo)
created_photos.append(photo)
await db.flush()
for p in created_photos:
await db.refresh(p)
return created_photos
@router.get("/{ticket_id}/photos", response_model=list[TicketPhotoOut])
async def list_photos(
ticket_id: int,
db: Annotated[AsyncSession, Depends(get_db)],
) -> list[TicketPhoto]:
"""List all photos for a ticket."""
await ticket_service.get_ticket(db, ticket_id)
result = await db.execute(
select(TicketPhoto)
.where(TicketPhoto.ticket_id == ticket_id)
.order_by(TicketPhoto.id)
)
return list(result.scalars().all())
+117
View File
@@ -0,0 +1,117 @@
"""Pydantic schemas for ticket CRUD, timeline, photos, and SLA."""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, Field
# ── Category ─────────────────────────────────────────────────────────
class CategoryOut(BaseModel):
id: int
type: str
name: str
parent_id: int | None = None
sla_urgency: str | None = None
model_config = {"from_attributes": True}
class CategoryTreeOut(CategoryOut):
children: list[CategoryTreeOut] = []
# ── Ticket ───────────────────────────────────────────────────────────
class TicketCreate(BaseModel):
unit_id: int | None = None
category_id: int | None = None
priority: str | None = None # urgent, high, medium, low
reporter: str | None = None
reported_via: str | None = None # whatsapp, phone, walk-in, qr, agent
description: str | None = None
assigned_to: int | None = None
class TicketUpdate(BaseModel):
status: str | None = None
unit_id: int | None = None
category_id: int | None = None
priority: str | None = None
reporter: str | None = None
reported_via: str | None = None
description: str | None = None
assigned_to: int | None = None
eta: datetime | None = None
cost: Decimal | None = None
parts_used: str | None = None
class TicketTimelineOut(BaseModel):
id: int
ticket_id: int
from_status: str | None = None
to_status: str | None = None
note: str | None = None
user_id: int | None = None
created_at: datetime
model_config = {"from_attributes": True}
class TicketPhotoOut(BaseModel):
id: int
ticket_id: int
photo_url: str
is_before: bool
model_config = {"from_attributes": True}
class TicketBrief(BaseModel):
"""Summary view for list endpoints."""
id: int
ticket_number: str
status: str
priority: str | None = None
unit_id: int | None = None
category_id: int | None = None
assigned_to: int | None = None
reporter: str | None = None
description: str | None = None
sla_deadline: datetime | None = None
reopen_count: int = 0
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class TicketOut(TicketBrief):
"""Full ticket detail with relations."""
closed_at: datetime | None = None
eta: datetime | None = None
cost: Decimal | None = None
parts_used: str | None = None
customer_rating: int | None = None
timeline: list[TicketTimelineOut] = []
photos: list[TicketPhotoOut] = []
sla_status: dict | None = None
class TicketListResponse(BaseModel):
items: list[TicketBrief]
total: int
page: int
page_size: int
# ── SLA ──────────────────────────────────────────────────────────────
class SLAStatusOut(BaseModel):
priority: str | None = None
response_deadline: str | None = None
resolution_deadline: str | None = None
response_breached: bool = False
resolution_breached: bool = False
current_status: str | None = None
+85
View File
@@ -15,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import hash_password from app.core.security import hash_password
from app.models.unit import Unit from app.models.unit import Unit
from app.models.user import User from app.models.user import User
from app.models.category import Category
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -118,3 +119,87 @@ async def seed_units(db: AsyncSession, json_path: str | Path | None = None) -> l
else: else:
logger.info("All units already exist") logger.info("All units already exist")
return created 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