Files
denya-onecare/app/schemas/ticket.py
T
root 43800345c1 feat(dashboard): technician performance report + real technician names
Wahab customer request (relay #748 v3 next-wave).

3a. Fix 'Tech #N' display:
- FM dashboard's "Technician Workload" card was built from
  `Tech #${t.assigned_to}`; it now reads Ticket.assigned_technician_name
  (falling back to 'Unassigned', matching /tickets).
- Template sweep confirms no other `Tech #` placeholder exists.

3b. Technician performance dashboard:
- New bearer-gated GET /api/tickets/tech-performance aggregating existing
  ticket columns only (no schema change): per-technician total assigned,
  completed, in progress, escalated, cancelled, pending, open tasks,
  completion rate, and created_at -> closed_at resolution time with explicit
  counts for finished tasks lacking a timestamp. Rows key on user id so
  same-name technicians stay separate; labels are real names. Fleet totals
  and an unassigned-ticket count are included.
- New /dashboard/tech-performance page (FM + CEO nav and links) with sortable
  table, completion bars and empty states; same-origin vendored assets only.
- Bucket map, service, schemas and tests in app/services/ticket.py,
  app/schemas/ticket.py, tests/test_tech_performance.py.

Tests: 111 passed (101 existing + 10 new).
2026-09-10 04:00:19 +00:00

194 lines
6.0 KiB
Python

"""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
# ── Unit ─────────────────────────────────────────────────────────────
class UnitOut(BaseModel):
id: int
property: str
apartment_code: str
building: str | None = None
floor: int | None = None
model_config = {"from_attributes": True}
# ── Category ─────────────────────────────────────────────────────────
class CategoryOut(BaseModel):
id: int
type: str
name: str
parent_id: int | None = None
sla_urgency: str | None = None
show_in_form: bool = True
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
customer_name: str | None = None
phone: str | None = None
reported_at: datetime | None = None # original report date for backdated/backfilled tickets; defaults to now when omitted
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
phone: 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
note: 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
assigned_technician_name: str | None = None
reporter: str | None = None
phone: str | None = None
description: str | None = None
sla_deadline: datetime | None = None
reopen_count: int = 0
reported_at: datetime | None = None
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] = []
unit: UnitOut | None = None
category: CategoryOut | None = None
sla_status: dict | None = None
class TicketListResponse(BaseModel):
items: list[TicketBrief]
total: int
page: int
page_size: int
# ── Technician performance ───────────────────────────────────────────
class TechnicianPerformanceOut(BaseModel):
"""Per-technician workload and outcome aggregate (by real name).
``open_tickets`` is ``total_assigned - completed - cancelled``;
``pending`` is the remainder bucket (statuses such as New/Logged/Triage/
Assigned plus verification stages) and keeps the named buckets summing to
``total_assigned``. ``avg_resolution_hours`` averages ``created_at ->
closed_at`` and is ``null`` when no finished task carries a timestamp —
``resolved_without_timestamps`` then says how many those are.
"""
technician_id: int
name: str
total_assigned: int
completed: int
closed: int
in_progress: int
escalated: int
cancelled: int
pending: int
open_tickets: int
completion_rate: float
avg_resolution_hours: float | None = None
resolved_with_timestamps: int
resolved_without_timestamps: int
status_breakdown: dict[str, int] = {}
class TechnicianPerformanceTotalsOut(BaseModel):
"""Fleet-wide roll-up of :class:`TechnicianPerformanceOut`."""
technicians: int
total_tickets: int
total_assigned: int
completed: int
closed: int
in_progress: int
escalated: int
cancelled: int
pending: int
open_tickets: int
completion_rate: float
avg_resolution_hours: float | None = None
resolved_with_timestamps: int
resolved_without_timestamps: int
unassigned_tickets: int
class TechnicianPerformanceReportOut(BaseModel):
"""Response for ``GET /api/tickets/tech-performance``."""
generated_at: datetime
technicians: list[TechnicianPerformanceOut]
totals: TechnicianPerformanceTotalsOut
# ── 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