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).
279 lines
11 KiB
Python
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
|