Files
denya-onecare/tests/test_tech_performance.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

279 lines
11 KiB
Python

"""Tests for the technician-performance dashboard and the ``Tech #N`` fix.
Anchors two Wahab-facing defects/requests:
1. The FM dashboard built its "Technician Workload" card from
``Tech #<user id>`` instead of the technician's real name. The template must
use ``assigned_technician_name`` (the same field ``/tickets`` already uses),
and no template may reintroduce a ``Tech #`` placeholder.
2. ``GET /api/tickets/tech-performance`` reports per-technician workload and
outcomes by real name: totals, per-status buckets, completion rate and
``created_at -> closed_at`` resolution times. Everything derives from
existing ticket columns — no schema change.
"""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
import pytest
from sqlalchemy import select
from app.core.database import async_session_factory
from app.models.ticket import Ticket
from app.models.user import User
pytestmark = pytest.mark.asyncio
TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "app" / "templates"
async def _login(client, email: str = "wahab@denya.com", password: str = "denya123") -> str:
resp = await client.post("/api/auth/login", json={"email": email, "password": password})
assert resp.status_code == 200, resp.text
return resp.json()["access_token"]
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
async def _user_id(email: str) -> int:
async with async_session_factory() as session:
user = (
await session.execute(select(User).where(User.email == email))
).scalar_one()
return user.id
async def _seed_tickets(specs: list[dict]) -> None:
"""Insert tickets directly so tests control status/timestamps exactly."""
async with async_session_factory() as session:
for i, spec in enumerate(specs):
session.add(Ticket(ticket_number=f"PAV-PERF-{i:05d}", **spec))
await session.commit()
async def _make_user(email: str, full_name: str, role: str = "Tech") -> int:
"""Create a user directly (no API) so tests can build same-name technicians."""
async with async_session_factory() as session:
user = User(email=email, password_hash="not-a-real-hash", full_name=full_name, role=role)
session.add(user)
await session.commit()
await session.refresh(user)
return user.id
def _ticket(status: str, assigned_to: int | None, *, created_at: datetime | None = None,
closed_at: datetime | None = None) -> dict:
spec: dict = {
"status": status,
"priority": "medium",
"description": f"{status} ticket",
"assigned_to": assigned_to,
}
if created_at is not None:
spec["created_at"] = created_at
if closed_at is not None:
spec["closed_at"] = closed_at
return spec
# ── 3a. 'Tech #N' regression ─────────────────────────────────────────
async def test_fm_dashboard_workload_uses_real_technician_names(client):
"""The FM dashboard must build the workload card from the real name field."""
resp = await client.get("/dashboard/fm")
assert resp.status_code == 200
body = resp.text
assert "Tech #" not in body
assert "assigned_technician_name" in body
async def test_no_template_reintroduces_tech_id_placeholder():
"""Sweep every template for the ``Tech #<id>`` placeholder."""
offenders = [
str(path.relative_to(TEMPLATES_DIR))
for path in TEMPLATES_DIR.rglob("*.html")
if "Tech #" in path.read_text(encoding="utf-8")
]
assert offenders == []
# ── Page + route wiring ──────────────────────────────────────────────
async def test_tech_performance_page_is_same_origin(client):
"""The new dashboard is reachable and uses only same-origin assets."""
resp = await client.get("/dashboard/tech-performance")
assert resp.status_code == 200
body = resp.text
assert "/api/tickets/tech-performance" in body
assert "https://" not in body
assert "cdn." not in body
async def test_base_nav_links_fm_and_ceo_to_tech_performance(client):
"""FM and CEO navigation exposes the report."""
for path in ("/dashboard/fm", "/dashboard/ceo"):
body = (await client.get(path)).text
assert "/dashboard/tech-performance" in body
async def test_tech_performance_requires_auth(client):
"""The aggregate endpoint is bearer-gated, like other dashboard data paths."""
resp = await client.get("/api/tickets/tech-performance")
assert resp.status_code == 401
async def test_tech_performance_route_not_shadowed_by_ticket_id(client):
"""`/tech-performance` is a literal route, not an int ticket id (no 422)."""
token = await _login(client)
resp = await client.get("/api/tickets/tech-performance", headers=_auth(token))
assert resp.status_code == 200
# ── 3b. Aggregation ──────────────────────────────────────────────────
async def test_tech_performance_empty_state(client):
"""No assigned tickets → empty rows and a fully zeroed roll-up."""
token = await _login(client)
resp = await client.get("/api/tickets/tech-performance", headers=_auth(token))
assert resp.status_code == 200
data = resp.json()
assert data["technicians"] == []
assert data["totals"]["technicians"] == 0
assert data["totals"]["total_assigned"] == 0
assert data["totals"]["completion_rate"] == 0.0
assert data["totals"]["avg_resolution_hours"] is None
async def test_tech_performance_aggregates_by_real_name(client):
"""Counts, completion rate, aging, sorting and totals per technician."""
prosper = await _user_id("prosper@denya.com")
sam = await _user_id("sam@denya.com")
await _seed_tickets(
[
# Prosper: 2 completed (one timestamped), 1 escalated, 1 in progress, 1 cancelled
_ticket("Completed", prosper, created_at=datetime(2026, 1, 1, 0, 0),
closed_at=datetime(2026, 1, 1, 10, 0)),
_ticket("Completed", prosper),
_ticket("Escalated", prosper),
_ticket("In Progress", prosper),
_ticket("Cancelled", prosper),
# Sam: 3 closed (timestamped), 1 assigned, 1 awaiting verification
_ticket("Closed", sam, created_at=datetime(2026, 1, 1, 0, 0),
closed_at=datetime(2026, 1, 2, 0, 0)),
_ticket("Closed", sam, created_at=datetime(2026, 1, 1, 0, 0),
closed_at=datetime(2026, 1, 3, 0, 0)),
_ticket("Closed", sam, created_at=datetime(2026, 1, 1, 0, 0),
closed_at=datetime(2026, 1, 4, 0, 0)),
_ticket("Assigned", sam),
_ticket("On-Field Verification", sam),
# Unassigned tickets are reported separately, never as a fake technician.
_ticket("Logged", None),
_ticket("New", None),
]
)
token = await _login(client)
resp = await client.get("/api/tickets/tech-performance", headers=_auth(token))
assert resp.status_code == 200
data = resp.json()
by_name = {t["name"]: t for t in data["technicians"]}
assert set(by_name) == {"Prosper", "Sam"}
assert all("Tech #" not in name for name in by_name)
prosper_row = by_name["Prosper"]
assert prosper_row["technician_id"] == prosper
assert prosper_row["total_assigned"] == 5
assert prosper_row["completed"] == 2
assert prosper_row["closed"] == 0
assert prosper_row["in_progress"] == 1
assert prosper_row["escalated"] == 1
assert prosper_row["cancelled"] == 1
assert prosper_row["pending"] == 0
# Buckets always reconcile with the assigned total.
assert (
prosper_row["completed"] + prosper_row["in_progress"] + prosper_row["escalated"]
+ prosper_row["cancelled"] + prosper_row["pending"]
) == prosper_row["total_assigned"]
assert prosper_row["open_tickets"] == 2
assert prosper_row["completion_rate"] == 40.0
assert prosper_row["avg_resolution_hours"] == 10.0
assert prosper_row["resolved_with_timestamps"] == 1
assert prosper_row["resolved_without_timestamps"] == 1
assert prosper_row["status_breakdown"] == {"Completed": 2, "Cancelled": 1, "Escalated": 1, "In Progress": 1}
sam_row = by_name["Sam"]
assert sam_row["total_assigned"] == 5
assert sam_row["completed"] == 3
assert sam_row["closed"] == 3
assert sam_row["in_progress"] == 0
assert sam_row["pending"] == 2
assert sam_row["open_tickets"] == 2
assert sam_row["completion_rate"] == 60.0
assert sam_row["avg_resolution_hours"] == 48.0
assert sam_row["resolved_without_timestamps"] == 0
assert sam_row["status_breakdown"] == {"Closed": 3, "Assigned": 1, "On-Field Verification": 1}
# Sorted by completed desc → Sam first.
assert [t["name"] for t in data["technicians"]] == ["Sam", "Prosper"]
totals = data["totals"]
assert totals["technicians"] == 2
assert totals["total_assigned"] == 10
assert totals["completed"] == 5
assert totals["closed"] == 3
assert totals["in_progress"] == 1
assert totals["escalated"] == 1
assert totals["cancelled"] == 1
assert totals["pending"] == 2
assert totals["open_tickets"] == 4
assert totals["completion_rate"] == 50.0
# Fleet average is weighted over tickets, not an average of per-tech averages.
assert totals["avg_resolution_hours"] == 38.5
assert totals["resolved_with_timestamps"] == 4
assert totals["resolved_without_timestamps"] == 1
assert totals["unassigned_tickets"] == 2
assert totals["total_tickets"] == 12
async def test_tech_performance_reports_counts_when_timestamps_absent(client):
"""Finished tasks without ``closed_at`` yield no average but a count."""
afful = await _user_id("afful@denya.com")
await _seed_tickets([
_ticket("Completed", afful),
_ticket("Closed", afful),
_ticket("Completed", afful),
])
token = await _login(client)
resp = await client.get("/api/tickets/tech-performance", headers=_auth(token))
row = next(t for t in resp.json()["technicians"] if t["name"] == "Afful")
assert row["completed"] == 3
assert row["closed"] == 1
assert row["avg_resolution_hours"] is None
assert row["resolved_with_timestamps"] == 0
assert row["resolved_without_timestamps"] == 3
async def test_tech_performance_groups_same_name_by_user_id(client):
"""Two technicians sharing a display name stay separate rows."""
first = await _make_user("kwame.one@denya.com", "Kwame Mensah")
second = await _make_user("kwame.two@denya.com", "Kwame Mensah")
await _seed_tickets([
_ticket("Completed", first),
_ticket("Completed", second),
_ticket("Escalated", second),
])
token = await _login(client)
resp = await client.get("/api/tickets/tech-performance", headers=_auth(token))
rows = [t for t in resp.json()["technicians"] if t["name"] == "Kwame Mensah"]
assert len(rows) == 2
assert {r["technician_id"] for r in rows} == {first, second}
by_id = {r["technician_id"]: r for r in rows}
assert by_id[first]["total_assigned"] == 1
assert by_id[second]["total_assigned"] == 2
assert by_id[second]["escalated"] == 1