Compare commits

..
Author SHA1 Message Date
abiba-bot 8ba04f9851 Merge pull request 'feat(dashboard): real technician names + technician performance dashboard (relay #748 v3)' (#16) from fm/denya-wahab-tech-perf-20260909 into main 2026-09-10 04:37:07 +00:00
root b7f36ac3b1 no-mistakes(review): Harden tech-performance tests, null comparator, and pending label 2026-09-10 04:07:31 +00:00
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
abiba-bot 57cbe34117 Merge pull request 'feat(whatsapp,branding): demo WhatsApp number env wiring + Denya logo assets' (#15) from fm/denya-nextwave-20260909 into main 2026-09-09 19:47:37 +00:00
root a6eb799efa feat(whatsapp,branding): WhatsApp demo-number wiring + Denya logo assets
WhatsApp demo path (relay #748):
- WHATSAPP_DEMO_TO config under the WhatsApp section (env-based, .env-only;
  .env.example keeps an empty placeholder; real numbers never enter source).
- build_demo_webhook_payload() in app/routers/whatsapp.py builds the Meta
  demo payload from it (fails closed when unset), so the webhook round trip
  logs from_number = demo number (surfaces in GET /api/whatsapp/mock-log) and
  the auto-reply targets the same number.
- tests/test_whatsapp_demo_number.py: default empty + never committed in
  tracked files, payload builder from/to, 200/403/401 gates unchanged.

Branding (logo-assets-v1, sha256-verified, same-origin app/static/branding):
- Login header uses h96 full lockup; logged-in topbar (base.html) uses h48 on
  a light chip (logo ink is ~2:1 vs the dark nav); favicons 32x32 + 16x16 in
  <head>. img-src 'self' data: blob: already allows /static/branding/*.
- tests/test_branding_assets.py: page placement + same-origin serving + CSP.
- AGENTS.md synced.
2026-09-09 19:32:21 +00:00
abiba-bot f1428335ef Merge pull request 'fix(security): add unsafe-eval to CSP script-src (Alpine.js runtime requires it)' (#14) from fm/fix-denya-csp-unsafe-eval-20260909 into main 2026-09-09 15:58:27 +00:00
root 40f1c0ecf6 fix(csp): add 'unsafe-eval' to script-src so Alpine.js initializes
Alpine 3.17.2's CDN build compiles every x-data/x-show/x-text expression
with new Function(), which the strict P0 CSP (script-src 'self'
'unsafe-inline') blocked. Every Alpine directive threw "Evaluating a
string as JavaScript violates ... 'unsafe-eval' is not an allowed
source", Alpine never initialized, and the loading overlay
(x-show="loading" in base.html) stayed visible forever on /login and
every Alpine-driven page.

Add 'unsafe-eval' to script-src (Alpine's documented CSP requirement for
its runtime); everything else in the header is unchanged. Regression test
asserts the /login CSP header carries 'unsafe-eval' inside script-src.

Verified live: headless chromium (playwright build 1243) shows zero
CSP/eval console errors after the fix, with Alpine applying
style="display:none" to the loading overlay; the pre-fix header produces
the Alpine Expression Error spam and leaves the overlay visible.
2026-09-09 15:45:52 +00:00
abiba-bot 7ca2924191 Merge pull request 'fix(whatsapp,roles): self-heal legacy whatsapp_log schema; map underscore role aliases' (#13) from fm/fix-denya-mocklog-roles-20260909 into main 2026-09-09 13:16:35 +00:00
root 0d79a582f6 no-mistakes(document): drop stale hand-copied test count from HARDENING.md 2026-09-09 13:06:19 +00:00
root 4e8b96ed0a fix(whatsapp,roles): self-heal legacy whatsapp_log schema; map underscore role aliases
CT115 (Mumuni relay #747) — two live-instance defects after PR #12:

1. GET /api/whatsapp/mock-log 500'd with a valid admin token:
   'no such column: whatsapp_log.message_text'. The model gained
   message_text/wa_message_id/ticket_number (and dropped command) in
   4afdc36 with no migration, so legacy DBs keep the (command, ...) shape.
   ensure_legacy_schema (startup, app/main.py) now adds the three missing
   columns idempotently and backfills legacy command bodies into
   message_text before dropping the obsolete NOT NULL command column, so
   both the mock-log read path and the ORM write path work on healed DBs.
   New producer/consumer regression (tests/test_whatsapp_log_legacy_heal.py)
   reproduces the exact OperationalError, then asserts 200 + data.

2. ROLE_ALIASES gap: underscore legacy roles (cs_rep, cs_manager,
   fm_dispatcher) were not mapped, so normalize_legacy_user_roles could not
   converge rows like user 18 (test@denya.com, role 'cs_rep') and the
   frontend stranded them on /tickets. Added the underscore aliases; tests
   assert normalize_role('cs_rep') == 'CS Rep' and a cs_rep row converges
   and authenticates.
2026-09-09 12:52:07 +00:00
abiba-bot 7b0365b135 Merge pull request 'fix(frontend): vendor Alpine.js + Tailwind same-origin (LAN-safe demo)' (#12) from fm/vendor-alpine-tailwind-locally-in-denya-00 into main 2026-09-09 12:41:21 +00:00
29 changed files with 1426 additions and 31 deletions
+7
View File
@@ -20,6 +20,13 @@ META_GRAPH_BASE=https://graph.facebook.com/v18.0
# Generate with: openssl rand -hex 32
WHATSAPP_WEBHOOK_SECRET=
# ── WhatsApp demo path (optional) ───────────────────────
# Expected sender/recipient number (E.164) for the WhatsApp demo round trip
# (webhook -> ticket -> mock-log). Set the real number ONLY on the deploy
# host's .env — keep this template an empty placeholder, never a live number.
# Empty (default): the demo payload builder fails closed (no fabricated sender).
WHATSAPP_DEMO_TO=
# ── Login rate limiting (P0) ────────────────────────────
# ~5 failed login attempts per 15 minutes per IP+email → HTTP 429
LOGIN_RATE_LIMIT_MAX_ATTEMPTS=5
+56 -5
View File
@@ -66,6 +66,22 @@ sign-up UI; users are created/managed by admins only (P0 hardening batch).
| POST | `/api/whatsapp/webhook` | `X-Webhook-Secret` header | Inbound message → ticket + log. Fail-closed: 403 when `WHATSAPP_WEBHOOK_SECRET` is unset or the header doesn't match |
| GET | `/api/whatsapp/mock-log` | Bearer | Recent webhook submissions (debug; auth required) |
`whatsapp_log` predates the real Meta webhook (the model gained
`message_text`/`wa_message_id`/`ticket_number` and dropped `command` without a
migration), so legacy tables keep the old `(command, …)` shape and every ORM
read/write 500s. `ensure_legacy_schema` (app/main.py) adds the missing columns
and backfills+drops the obsolete NOT NULL `command` column idempotently at
startup — do not hand-edit legacy DBs, ship a self-heal there instead.
Demo WhatsApp round trip: `WHATSAPP_DEMO_TO` (E.164, .env-only — never commit
a real number; `.env.example` keeps an empty placeholder) is the expected
sender/recipient for the demo path.
`app/routers/whatsapp.py::build_demo_webhook_payload` builds a Meta webhook
payload from it (fails closed when unset), so posting it to
`POST /api/whatsapp/webhook` with the secret logs `from_number` = demo number
(visible via `GET /api/whatsapp/mock-log`) and the auto-reply targets the same
number. Covered by `tests/test_whatsapp_demo_number.py`.
### Pages (Sprint 3) — Jinja2 templates at `app/templates/`
| Method | Path | Auth | Description |
|--------|------|------|-------------|
@@ -73,6 +89,7 @@ sign-up UI; users are created/managed by admins only (P0 hardening batch).
| GET | `/dashboard/cs` | Client | CS dashboard |
| GET | `/dashboard/fm` | Client | FM dashboard |
| GET | `/dashboard/ceo` | Client | CEO dashboard |
| GET | `/dashboard/tech-performance` | Client | Technician performance report (FM + CEO nav) |
| GET | `/tickets` | Client | All Issues filterable table |
| GET | `/tickets/new` | Client | Create Issue form |
| GET | `/tickets/{id}` | Client | Issue detail with timeline |
@@ -80,6 +97,20 @@ sign-up UI; users are created/managed by admins only (P0 hardening batch).
Frontend: Alpine.js + Tailwind CSS vendored same-origin (no CDN) — see
"Frontend assets" below. Auth state in localStorage. Role-based nav routing in `base.html`.
### Technician performance (Wahab request)
`GET /api/tickets/tech-performance` (Bearer) aggregates per-technician workload/outcomes by
**real name** using only existing ticket columns (`assigned_to`/`status`/`created_at`/`closed_at`
→ `users.full_name`) — no schema change. Aggregation lives in
`app/services/ticket.py::get_technician_performance`; bucket map `_TECH_STATUS_BUCKETS` defines
`completed` (Completed/Closed), `in_progress`, `escalated`, `cancelled`, and `pending` (remainder),
so the buckets always sum to `total_assigned`. `completion_rate = completed/total_assigned`;
`avg_resolution_hours` averages `created_at → closed_at` only where `closed_at` is set, with
`resolved_without_timestamps` counting finished tasks that lack one. Rows key on user id (same-name
techs stay separate), sorted completed desc → workload → name. UI:
`app/templates/dashboard/tech-performance.html`, linked from FM + CEO nav and the FM
"Technician Workload" card — that card reads `Ticket.assigned_technician_name`, never an id
placeholder. Regression: `tests/test_tech_performance.py`.
### Frontend assets (vendored, LAN-safe)
- Alpine.js 3.17.2 + Tailwind Play 3.4.17 are committed under `app/static/vendor/`
and served at `/static/vendor/…` (mounted in `app/main.py`, versioned
@@ -91,9 +122,26 @@ Frontend: Alpine.js + Tailwind CSS vendored same-origin (no CDN) — see
`alpine-3.17.2.min.js`; the tailwind play file does not, e.g.
`tailwind-3.4.17.js`), then bump the `<script src>` + the
`VENDORED_SCRIPTS` tuple in the regression file `tests/test_frontend_vendoring.py`.
- HTML pages ship `Cache-Control: no-cache` and CSP is self-only
(`script-src`/`style-src 'self' 'unsafe-inline'`, `connect-src 'self'`); no
CDN host is allowed in CSP (`app/main.py::SecurityHeadersMiddleware`).
- HTML pages ship `Cache-Control: no-cache` and CSP allows no external host
(`script-src 'self' 'unsafe-inline' 'unsafe-eval'`, `style-src 'self'
'unsafe-inline'`, `connect-src 'self'`); no CDN host is allowed in CSP
(`app/main.py::SecurityHeadersMiddleware`). `'unsafe-eval'` is required by
the Alpine 3.17.2 CDN build: its evaluator compiles every `x-*` expression
with `new Function()`, and without it CSP blocks Alpine entirely (stuck
loading overlay on every page) — covered by
`test_csp_script_src_allows_unsafe_eval_for_alpine` in
`tests/test_frontend_vendoring.py`.
### Branding (logo)
Official Denya Developers logo derivatives are committed under
`app/static/branding/` (Gitea release `logo-assets-v1`, sha256-verified
monochrome forest-green lockup; sourced from the Gitea release, never from
kagentz). Placement: login header uses `denya-logo-h96.png` (full lockup); the
logged-in topbar (base.html nav) uses `denya-logo-h48.png` on a light chip
(logo ink is only ~2:1 against the dark `#0d2b18` nav — keep a light chip
there); favicons 32x32+16x16 declared in `base.html <head>`.
`img-src 'self' data: blob:` already covers `/static/branding/*` — no CSP
change. Regression coverage: `tests/test_branding_assets.py`.
### Tickets (Sprint 2)
| Method | Path | Auth | Description |
@@ -123,14 +171,17 @@ Frontend: Alpine.js + Tailwind CSS vendored same-origin (no CDN) — see
old open register) fail closed at login/JWT and can never be recreated via the
API (422). Startup self-heals (lifespan in `app/main.py`, helpers in
`app/services/seed.py`) converge legacy rows: `normalize_legacy_user_roles`
maps unambiguous alias nicknames onto canonical roles, and
maps unambiguous alias nicknames onto canonical roles (space and underscore
forms alike — `cs rep`/`cs_rep` → `CS Rep`, `fm dispatcher`/`fm_dispatcher`,
`cs manager`/`cs_manager`), and
`normalize_legacy_user_emails` lowercases stored emails — login and the admin
create-user duplicate check both compare on the lowercased form, so pre-P0
mixed-case emails are never silently locked out.
- Use `require_roles(*ADMIN_ROLES)` for admin gates; `sub` claim holds string user ID
- Security headers middleware in `app/main.py`: X-Frame-Options DENY +
nosniff on everything, CSP on HTML pages, HSTS when `X-Forwarded-Proto: https`
(CSP is self-only — frontend libs are vendored, see "Frontend assets")
(CSP allows no external host — frontend libs are vendored, see "Frontend
assets"; `script-src` carries `'unsafe-eval'` for the Alpine runtime)
## Ticket System (Sprint 2)
+1 -1
View File
@@ -16,7 +16,7 @@
- **Working & verified:** auth (JWT 30m/7d, bcrypt, RBAC via `require_roles`), ticket
CRUD with 16-status `VALID_TRANSITIONS` state machine, SLA engine, photo uploads,
category/unit hierarchy, 3 role dashboards (CS/FM/CEO), Alembic migrations with
legacy-schema self-heal. **32 pytest tests pass.**
legacy-schema self-heal. **pytest suite passes.**
- **Live:** container `denya-onecare` on LXC `scottdenya` (192.168.68.75:8000),
image built 2026-08-02, `restart: unless-stopped`.
- **Demo-only posture resolved (PR #10 + P0 batch):** `SECRET_KEY` now fails
+8
View File
@@ -39,6 +39,14 @@ class Settings(BaseSettings):
# Shared secret for inbound webhook POSTs (header ``X-Webhook-Secret``).
# Fail-closed: when unset/empty the webhook rejects every message.
WHATSAPP_WEBHOOK_SECRET: str = ""
# Expected sender/recipient number (E.164) for the WhatsApp demo round
# trip (webhook -> ticket -> mock-log). Set per deployment in .env only —
# never commit a real number. When set, the demo payload builder
# (``app/routers/whatsapp.py::build_demo_webhook_payload``) originates
# messages from it, the auto-reply targets it, and ``/api/whatsapp/mock-log``
# surfaces it. Empty (default) means the demo payload cannot be built:
# the helper fails closed rather than fabricating a sender.
WHATSAPP_DEMO_TO: str = ""
# ── Login rate limiting ──────────────────────────────────────────
LOGIN_RATE_LIMIT_MAX_ATTEMPTS: int = 5
+6 -2
View File
@@ -12,8 +12,9 @@ uses (PRD §4, ``app/services/seed.py``, the frontend nav in base.html):
Legacy databases created under the pre-P0 open-registration builds can carry
lowercase/nickname role strings (``technician``, ``cs``, ``fm``, ``ceo``,
``admin``, ``superadmin`` …). ``ROLE_ALIASES`` maps the *unambiguous*
nicknames onto a canonical role so startup normalization (see
``admin``, ``superadmin`` …) and underscore variants of the space-separated
ones (``cs_rep``, ``cs_manager``, ``fm_dispatcher``). ``ROLE_ALIASES`` maps
the *unambiguous* nicknames onto a canonical role so startup normalization (see
``app/services/seed.py::normalize_legacy_user_roles``) can converge the data.
``admin`` / ``superadmin`` are deliberately NOT aliased: they are
@@ -52,10 +53,13 @@ ROLE_ALIASES: dict[str, str] = {
"tech": TECHNICIAN_ROLE,
"cs": "CS Rep",
"cs rep": "CS Rep",
"cs_rep": "CS Rep",
"cs representative": "CS Rep",
"cs manager": "CS Manager",
"cs_manager": "CS Manager",
"fm": "FM Dispatcher",
"fm dispatcher": "FM Dispatcher",
"fm_dispatcher": "FM Dispatcher",
"ceo": "CEO",
"director": "Director",
}
+48 -3
View File
@@ -52,6 +52,47 @@ async def ensure_legacy_schema(conn) -> None:
text("UPDATE tickets SET reported_at = created_at WHERE reported_at IS NULL")
)
logger.info("Added missing tickets.reported_at column (legacy database)")
# whatsapp_log predates the real Meta webhook (the model gained
# message_text/wa_message_id/ticket_number and dropped `command` in commit
# 4afdc36 with no migration), so legacy DBs still carry the old shape and
# every read/write through the ORM 500s (no such column: message_text).
result = await conn.execute(
text("SELECT name FROM sqlite_master WHERE type='table' AND name='whatsapp_log'")
)
if result.scalar():
result = await conn.execute(text("PRAGMA table_info(whatsapp_log)"))
log_columns = {row[1] for row in result}
if "message_text" not in log_columns:
await conn.execute(
text("ALTER TABLE whatsapp_log ADD COLUMN message_text TEXT NOT NULL DEFAULT ''")
)
logger.info("Added missing whatsapp_log.message_text column (legacy database)")
if "wa_message_id" not in log_columns:
await conn.execute(
text("ALTER TABLE whatsapp_log ADD COLUMN wa_message_id VARCHAR(100)")
)
logger.info("Added missing whatsapp_log.wa_message_id column (legacy database)")
if "ticket_number" not in log_columns:
await conn.execute(
text("ALTER TABLE whatsapp_log ADD COLUMN ticket_number VARCHAR(30)")
)
logger.info("Added missing whatsapp_log.ticket_number column (legacy database)")
if "command" in log_columns:
# Legacy rows stored the message body in `command`, which the model
# no longer defines (NOT NULL, no default): any ORM insert omitting
# it would violate NOT NULL. Preserve the old bodies in
# message_text, then drop the obsolete column to match the model.
await conn.execute(
text(
"UPDATE whatsapp_log SET message_text = command "
"WHERE (message_text IS NULL OR message_text = '') "
"AND command IS NOT NULL AND command != ''"
)
)
await conn.execute(text("ALTER TABLE whatsapp_log DROP COLUMN command"))
logger.info("Dropped obsolete whatsapp_log.command column (legacy database)")
result = await conn.execute(
text(
"UPDATE categories SET name = 'Missing Item' "
@@ -103,8 +144,12 @@ class SecurityHeadersMiddleware:
* CSP on HTML pages (login + app pages). Alpine.js and Tailwind are
vendored same-origin (``/static/vendor/``), so no external hosts are
allowed and the page is fully self-contained — safe on LAN-only demo
clients. Inline scripts/styles stay enabled for the Alpine/tailwind
runtime;
clients. ``script-src`` keeps ``'unsafe-inline'`` for the inline
``tailwind.config``/``app()`` blocks and ``'unsafe-eval'`` because
Alpine 3.17.2's expression evaluator compiles ``x-data``/``x-show``/
``x-text`` etc. with ``new Function()`` — without ``'unsafe-eval'`` CSP
blocks every Alpine expression and the loading overlay never clears;
style-src keeps ``'unsafe-inline'`` for the Tailwind runtime;
* ``Cache-Control: no-cache`` on HTML pages so templates always
revalidate (the vendored assets themselves are cached immutably via
versioned filenames);
@@ -115,7 +160,7 @@ class SecurityHeadersMiddleware:
HSTS = "max-age=31536000; includeSubDomains"
CSP = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob:; "
"font-src 'self' data:; "
+8
View File
@@ -33,6 +33,14 @@ async def ceo_dashboard(request: Request):
return templates.TemplateResponse(request, "dashboard/ceo.html")
@router.get("/dashboard/tech-performance", response_class=HTMLResponse)
async def tech_performance_dashboard(request: Request):
"""Technician performance report (FM + CEO). Data comes from
``GET /api/tickets/tech-performance``; the page itself follows the same
client-side auth pattern as the other dashboards."""
return templates.TemplateResponse(request, "dashboard/tech-performance.html")
@router.get("/tickets", response_class=HTMLResponse)
async def ticket_list(request: Request):
return templates.TemplateResponse(request, "tickets/list.html")
+17
View File
@@ -23,6 +23,7 @@ from app.schemas.ticket import (
CategoryOut,
CategoryTreeOut,
SLAStatusOut,
TechnicianPerformanceReportOut,
TicketBrief,
TicketCreate,
TicketListResponse,
@@ -248,6 +249,22 @@ async def list_tickets(
return TicketListResponse(items=items, total=total, page=page, page_size=effective_page_size)
@router.get("/tech-performance", response_model=TechnicianPerformanceReportOut)
async def technician_performance(
db: Annotated[AsyncSession, Depends(get_db)],
_current_user: Annotated[User, Depends(get_current_user)],
) -> dict:
"""Per-technician performance aggregate for the FM/CEO dashboards.
Technician names come from ``users.full_name`` (never ``Tech #<id>``);
counts and resolution times are derived from existing ticket columns, so no
schema change is involved. Registered before ``/{ticket_id}`` so the literal
path is not swallowed by the int-typed ticket-id route. Requires a bearer
token, matching every other endpoint that powers a logged-in dashboard.
"""
return await ticket_service.get_technician_performance(db)
@router.get("/{ticket_id}/transitions")
async def get_ticket_transitions(
ticket_id: int,
+41
View File
@@ -46,6 +46,47 @@ REPLY_TEMPLATE = (
)
# ── WhatsApp demo round trip (WHATSAPP_DEMO_TO) ─────────────────────
def build_demo_webhook_payload(
text: str = "Demo message — Denya OneCare WhatsApp round trip",
wa_message_id: str = "wamid.demo.000001",
) -> dict:
"""Build a Meta webhook payload for the WhatsApp demo round trip.
The message ``from`` is ``settings.WHATSAPP_DEMO_TO`` (E.164), so the demo
surfaces the expected number end-to-end: the webhook logs it in
``whatsapp_log`` (visible via ``GET /api/whatsapp/mock-log``) and the
auto-reply is sent back to the same number. The demo number is configured
per deployment in .env (never committed); when it is unset this raises
rather than fabricating a sender (same fail-closed posture as the webhook
secret).
"""
demo_to = (settings.WHATSAPP_DEMO_TO or "").strip()
if not demo_to:
raise RuntimeError(
"WHATSAPP_DEMO_TO is not configured — set the demo sender number "
"in .env to run the WhatsApp demo round trip."
)
return {
"object": "whatsapp_business_account",
"entry": [
{
"id": "1",
"changes": [
{
"id": wa_message_id,
"message": {
"from": demo_to,
"id": wa_message_id,
"text": {"text": text},
},
}
],
}
],
}
# ── Meta Graph API helpers ──────────────────────────────────────────
async def send_whatsapp_reply(
to_phone: str,
+54
View File
@@ -129,6 +129,60 @@ class TicketListResponse(BaseModel):
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
+163
View File
@@ -345,6 +345,169 @@ async def update_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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 793 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

+19 -9
View File
@@ -4,6 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Denya OneCare</title>
<!-- Favicons (same-origin app/static/branding) -->
<link rel="icon" type="image/png" sizes="32x32" href="/static/branding/denya-logo-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/branding/denya-logo-16x16.png">
<!-- Vendored same-origin (no CDN): app/static/vendor/ — LAN-safe demo -->
<script src="/static/vendor/alpine-3.17.2.min.js" defer></script>
<script src="/static/vendor/tailwind-3.4.17.js"></script>
@@ -68,15 +71,11 @@
<!-- Left side -->
<div class="flex items-center space-x-4">
<a href="/dashboard/cs" class="flex items-center space-x-3">
<!-- Denya Developers Logo Mark -->
<div class="w-9 h-9 bg-gold rounded-lg flex items-center justify-center shadow-sm">
<svg class="w-5 h-5 text-[#0d2b18]" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
</svg>
</div>
<!-- Denya Developers logo (same-origin app/static/branding) -->
<img src="/static/branding/denya-logo-h48.png" alt="Denya Developers"
class="h-9 w-auto bg-white rounded-md px-1.5 py-1 shadow-sm">
<div class="flex flex-col">
<span class="text-white font-bold text-base leading-tight">Denya Developers</span>
<span class="text-gold text-xs leading-tight font-medium">OneCare</span>
<span class="text-gold text-sm leading-tight font-bold">OneCare</span>
</div>
</a>
<!-- Nav Links -->
@@ -90,6 +89,7 @@
<template x-if="isFM">
<div class="hidden md:flex space-x-1 ml-6">
<a href="/dashboard/fm" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath === '/dashboard/fm' ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Dashboard</a>
<a href="/dashboard/tech-performance" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath === '/dashboard/tech-performance' ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Tech Performance</a>
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">All Issues</a>
<a href="/tickets/new" class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-denya-800/50 transition-colors">Create Issue</a>
</div>
@@ -97,6 +97,7 @@
<template x-if="isExecutive">
<div class="hidden md:flex space-x-1 ml-6">
<a href="/dashboard/ceo" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath === '/dashboard/ceo' ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Dashboard</a>
<a href="/dashboard/tech-performance" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath === '/dashboard/tech-performance' ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Tech Performance</a>
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath.startsWith('/tickets') ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Issues</a>
</div>
</template>
@@ -114,16 +115,25 @@
<!-- Mobile Nav -->
<div class="md:hidden border-b bg-[#0d2b18] border-denya-800" x-show="isLoggedIn" x-cloak>
<template x-if="isCS || isFM">
<template x-if="isCS">
<div class="flex overflow-x-auto px-4 py-2 space-x-2">
<a href="/dashboard/cs" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/cs' ? 'bg-denya-800 text-gold' : 'text-gray-300'">Dashboard</a>
<a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-800 text-gold' : 'text-gray-300'">Issues</a>
<a href="/tickets/new" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-300">New Issue</a>
</div>
</template>
<template x-if="isFM">
<div class="flex overflow-x-auto px-4 py-2 space-x-2">
<a href="/dashboard/fm" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/fm' ? 'bg-denya-800 text-gold' : 'text-gray-300'">Dashboard</a>
<a href="/dashboard/tech-performance" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/tech-performance' ? 'bg-denya-800 text-gold' : 'text-gray-300'">Tech Performance</a>
<a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-800 text-gold' : 'text-gray-300'">Issues</a>
<a href="/tickets/new" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-300">New Issue</a>
</div>
</template>
<template x-if="isExecutive">
<div class="flex overflow-x-auto px-4 py-2 space-x-2">
<a href="/dashboard/ceo" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/ceo' ? 'bg-denya-800 text-gold' : 'text-gray-300'">CEO Dashboard</a>
<a href="/dashboard/tech-performance" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/tech-performance' ? 'bg-denya-800 text-gold' : 'text-gray-300'">Tech Performance</a>
<a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-300">Issues</a>
</div>
</template>
+6 -3
View File
@@ -1,9 +1,12 @@
{% extends "base.html" %}
{% block content %}
<div x-data="ceoDashboard()" x-init="init()">
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Executive Dashboard</h1>
<p class="text-gray-500 mt-1">Read-only strategic overview</p>
<div class="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 class="text-2xl font-bold text-gray-900">Executive Dashboard</h1>
<p class="text-gray-500 mt-1">Read-only strategic overview</p>
</div>
<a href="/dashboard/tech-performance" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-700">Technician Performance →</a>
</div>
<!-- Executive KPI Cards -->
+8 -3
View File
@@ -80,7 +80,10 @@
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- Tech Workload -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Technician Workload</h3>
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide">Technician Workload</h3>
<a href="/dashboard/tech-performance" class="text-xs text-denya-600 hover:text-denya-800">Performance →</a>
</div>
<div class="space-y-3">
<template x-for="tech in techWorkload" :key="tech.id">
<div class="flex items-center justify-between p-2 hover:bg-gray-50 rounded">
@@ -251,11 +254,13 @@
this.kpi.westJobs = active.filter(t => t.unit_id && unitMap[t.unit_id] === 'West').length;
} catch (e) { console.error('Property stats error', e); }
// Tech workload (simulated from assigned_to counts)
// Tech workload — real technician names (Ticket.assigned_technician_name),
// never an id placeholder; keyed on user id so two people sharing a
// display name stay separate rows.
const techMap = {};
active.forEach(t => {
if (t.assigned_to) {
if (!techMap[t.assigned_to]) techMap[t.assigned_to] = { id: t.assigned_to, name: `Tech #${t.assigned_to}`, activeJobs: 0 };
if (!techMap[t.assigned_to]) techMap[t.assigned_to] = { id: t.assigned_to, name: t.assigned_technician_name || 'Unassigned', activeJobs: 0 };
techMap[t.assigned_to].activeJobs++;
}
});
@@ -0,0 +1,239 @@
{% extends "base.html" %}
{% block content %}
<div x-data="techPerformance()" x-init="init()">
<div class="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 class="text-2xl font-bold text-gray-900">Technician Performance</h1>
<p class="text-gray-500 mt-1">Tasks completed per technician, by name — FM &amp; CEO view</p>
</div>
<div class="flex items-center space-x-2">
<a x-show="isFM" href="/dashboard/fm" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-700">FM Dashboard</a>
<a x-show="isExecutive" href="/dashboard/ceo" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-700">CEO Dashboard</a>
<button @click="loadData()" class="px-3 py-2 text-sm bg-denya-700 text-white rounded-lg hover:bg-denya-800">Refresh</button>
</div>
</div>
<!-- KPI Cards -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-8">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Technicians</p>
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="totals.technicians || 0"></p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Assigned</p>
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="totals.total_assigned || 0"></p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Completed</p>
<p class="text-3xl font-bold text-green-600 mt-1" x-text="totals.completed || 0"></p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Completion Rate</p>
<p class="text-3xl font-bold mt-1" :class="rateClass(totals.completion_rate)" x-text="formatRate(totals.completion_rate)"></p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Avg Resolution</p>
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="formatHours(totals.avg_resolution_hours)"></p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Open Tasks</p>
<p class="text-3xl font-bold text-orange-600 mt-1" x-text="totals.open_tickets || 0"></p>
</div>
</div>
<!-- Status strip -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 flex items-center justify-between">
<span class="text-sm font-medium text-sky-700">In Progress</span>
<span class="text-2xl font-bold text-sky-700" x-text="totals.in_progress || 0"></span>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 flex items-center justify-between">
<span class="text-sm font-medium text-red-700">Escalated</span>
<span class="text-2xl font-bold text-red-700" x-text="totals.escalated || 0"></span>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 flex items-center justify-between">
<span class="text-sm font-medium text-gray-600">Pending / In review</span>
<span class="text-2xl font-bold text-gray-700" x-text="totals.pending || 0"></span>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 flex items-center justify-between">
<span class="text-sm font-medium text-gray-500">Cancelled</span>
<span class="text-2xl font-bold text-gray-500" x-text="totals.cancelled || 0"></span>
</div>
</div>
<!-- Error state -->
<div x-show="error" class="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">
<span class="font-semibold">Could not load technician performance.</span>
<span x-text="error"></span>
</div>
<!-- Per-technician table -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
<div class="px-5 py-4 border-b border-gray-200 flex flex-wrap items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wide">By Technician</h3>
<div class="flex items-center space-x-2 text-sm">
<label for="tech-sort" class="text-gray-500">Sort by</label>
<select id="tech-sort" x-model="sortKey" class="border border-gray-300 rounded-lg px-2 py-1.5 text-sm text-gray-700 bg-white">
<option value="completed">Completed</option>
<option value="total_assigned">Workload (assigned)</option>
<option value="completion_rate">Completion rate</option>
<option value="avg_resolution_hours">Avg resolution</option>
<option value="name">Name</option>
</select>
<button @click="dir = dir === 'desc' ? 'asc' : 'desc'" class="px-2 py-1.5 text-xs border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-600" x-text="dir === 'desc' ? 'Desc ↓' : 'Asc ↑'"></button>
</div>
</div>
<div class="overflow-x-auto" x-show="technicians.length">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
<tr>
<th class="px-5 py-3 text-left">Technician</th>
<th class="px-4 py-3 text-right">Assigned</th>
<th class="px-4 py-3 text-right">Completed</th>
<th class="px-4 py-3 text-right">In Progress</th>
<th class="px-4 py-3 text-right">Escalated</th>
<th class="px-4 py-3 text-right">Pending</th>
<th class="px-4 py-3 text-right">Cancelled</th>
<th class="px-5 py-3 text-left min-w-[160px]">Completion Rate</th>
<th class="px-4 py-3 text-right">Avg Resolution</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<template x-for="tech in sortedTechnicians" :key="tech.technician_id">
<tr class="hover:bg-gray-50 transition" :title="breakdownTitle(tech)">
<td class="px-5 py-3">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-denya-100 rounded-full flex items-center justify-center text-sm font-medium text-denya-700" x-text="initial(tech.name)"></div>
<span class="font-medium text-gray-800" x-text="tech.name"></span>
</div>
</td>
<td class="px-4 py-3 text-right font-medium text-gray-700" x-text="tech.total_assigned"></td>
<td class="px-4 py-3 text-right font-semibold text-green-700" x-text="tech.completed"></td>
<td class="px-4 py-3 text-right text-sky-700" x-text="tech.in_progress"></td>
<td class="px-4 py-3 text-right" :class="tech.escalated ? 'text-red-600 font-medium' : 'text-gray-400'" x-text="tech.escalated"></td>
<td class="px-4 py-3 text-right text-gray-600" x-text="tech.pending"></td>
<td class="px-4 py-3 text-right" :class="tech.cancelled ? 'text-gray-500' : 'text-gray-300'" x-text="tech.cancelled"></td>
<td class="px-5 py-3">
<div class="flex items-center space-x-2">
<div class="flex-1 bg-gray-100 rounded-full h-2.5 overflow-hidden">
<div class="h-full rounded-full transition-all" :class="barClass(tech.completion_rate)" :style="'width: ' + Math.min(tech.completion_rate, 100) + '%'"></div>
</div>
<span class="text-xs font-medium text-gray-600 w-12 text-right" x-text="formatRate(tech.completion_rate)"></span>
</div>
</td>
<td class="px-4 py-3 text-right text-gray-700">
<span x-text="formatHours(tech.avg_resolution_hours)"></span>
<span x-show="tech.resolved_without_timestamps > 0" class="block text-[11px] text-gray-400"
x-text="tech.resolved_without_timestamps + ' task' + (tech.resolved_without_timestamps === 1 ? '' : 's') + ' without timestamps'"></span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Empty state -->
<div x-show="!technicians.length && !error" class="px-5 py-16 text-center">
<p class="text-gray-500 font-medium">No technician assignments yet</p>
<p class="text-gray-400 text-sm mt-1">Once tickets are assigned to a technician they will appear here with their completion rate and resolution time.</p>
</div>
<div x-show="technicians.length" class="px-5 py-3 border-t border-gray-100 text-xs text-gray-400 flex flex-wrap items-center justify-between gap-2">
<span>Completion rate = completed (Completed + Closed) ÷ assigned. Avg resolution spans created → closed where the close timestamp exists.</span>
<span><span x-text="totals.unassigned_tickets || 0"></span> ticket(s) have no technician assigned and are excluded from the rows above.</span>
</div>
</div>
</div>
<script>
function techPerformance() {
return {
technicians: [],
totals: {},
sortKey: 'completed',
dir: 'desc',
error: '',
get isFM() { return app().isFM },
get isExecutive() { return app().isExecutive },
get sortedTechnicians() {
const list = [...this.technicians];
const key = this.sortKey;
const flip = this.dir === 'asc' ? 1 : -1;
list.sort((a, b) => {
if (key === 'name') {
return flip * a.name.localeCompare(b.name);
}
let av = a[key];
let bv = b[key];
// Missing resolution times sort last in either direction;
// two missing values fall through to the name tiebreak.
if (key === 'avg_resolution_hours') {
const aMissing = av === null || av === undefined;
const bMissing = bv === null || bv === undefined;
if (aMissing || bMissing) {
if (aMissing && bMissing) return a.name.localeCompare(b.name);
return aMissing ? 1 : -1;
}
}
av = av || 0;
bv = bv || 0;
if (av === bv) return a.name.localeCompare(b.name);
return flip * (av - bv);
});
return list;
},
async init() {
await this.loadData();
},
async loadData() {
this.error = '';
try {
const data = await app().apiGet('/api/tickets/tech-performance');
this.technicians = data?.technicians || [];
this.totals = data?.totals || {};
} catch (e) {
this.error = e.message || 'Unknown error';
}
},
formatRate(rate) {
if (rate === null || rate === undefined) return '—';
return `${rate}%`;
},
formatHours(hours) {
if (hours === null || hours === undefined) return '—';
if (hours < 1) return `${Math.round(hours * 60)}m`;
return `${hours}h`;
},
initial(name) {
return (name || '?').charAt(0).toUpperCase();
},
rateClass(rate) {
if (!rate) return 'text-gray-400';
if (rate >= 70) return 'text-green-600';
if (rate >= 40) return 'text-yellow-600';
return 'text-red-600';
},
barClass(rate) {
if (rate >= 70) return 'bg-green-500';
if (rate >= 40) return 'bg-yellow-500';
return 'bg-red-500';
},
breakdownTitle(tech) {
const parts = Object.entries(tech.status_breakdown || {}).map(([s, n]) => `${s}: ${n}`);
return parts.length ? parts.join(' · ') : '';
}
}
}
</script>
{% endblock %}
+3 -5
View File
@@ -4,11 +4,9 @@
<div class="w-full max-w-md" x-data="loginForm()">
<div class="bg-white rounded-2xl shadow-lg p-8">
<div class="text-center mb-8">
<div class="mx-auto w-16 h-16 bg-denya-100 rounded-full flex items-center justify-center mb-4">
<svg class="w-8 h-8 text-denya-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
</div>
<!-- Denya Developers full lockup (same-origin app/static/branding) -->
<img src="/static/branding/denya-logo-h96.png" alt="Denya Developers"
class="mx-auto mb-4 h-24 w-auto">
<h1 class="text-2xl font-bold text-gray-900">Denya OneCare</h1>
<p class="text-gray-500 mt-1">Sign in to your dashboard</p>
</div>
+80
View File
@@ -0,0 +1,80 @@
"""Denya logo branding — repo-local assets under app/static/branding/.
The official Denya Developers logo derivatives (Gitea release
``logo-assets-v1``, sha256-verified) are committed same-origin under
``app/static/branding/`` like the vendored frontend libraries — no CDN, no CSP
change needed (``img-src 'self' data: blob:`` already covers them).
Placement contract:
* Login page header uses the h96 full lockup (``denya-logo-h96.png``).
* Dashboard topbar (base.html nav) uses the h48 full lockup (compact spot:
the DEVELOPERS subtext is unreadable below ~48px, so the mark reads as
symbol+DENYA — the intended compact treatment).
* Favicon: 32x32 declared first, 16x16 declared, both in <head>.
"""
from __future__ import annotations
import re
import pytest
from httpx import AsyncClient
pytestmark = pytest.mark.asyncio
BRANDING_ASSETS = (
"denya-logo.png",
"denya-logo-trimmed.png",
"denya-logo-h48.png",
"denya-logo-h96.png",
"denya-logo-64x64.png",
"denya-logo-32x32.png",
"denya-logo-16x16.png",
)
def _directive_sources(csp: str, directive: str) -> list[str]:
"""Return the source list of one CSP directive (e.g. ``img-src``)."""
for part in csp.split(";"):
tokens = part.split()
if tokens and tokens[0].strip() == directive:
return [t.strip() for t in tokens[1:]]
return []
async def test_login_page_header_uses_h96_logo(client: AsyncClient):
"""/login must carry the h96 full-lockup logo (same-origin URL)."""
resp = await client.get("/login")
assert resp.status_code == 200, resp.text
assert "/static/branding/denya-logo-h96.png" in resp.text
async def test_topbar_and_favicon_on_dashboard_pages(client: AsyncClient):
"""Dashboard chrome (base.html) carries h48 topbar logo + both favicons."""
resp = await client.get("/dashboard/fm")
assert resp.status_code == 200, resp.text
assert "/static/branding/denya-logo-h48.png" in resp.text
# Favicon 32x32 with 16x16 declared in <head> (link rel="icon").
assert 'rel="icon" type="image/png" sizes="32x32" href="/static/branding/denya-logo-32x32.png"' in resp.text
assert 'rel="icon" type="image/png" sizes="16x16" href="/static/branding/denya-logo-16x16.png"' in resp.text
async def test_branding_assets_served_same_origin(client: AsyncClient):
"""Every committed branding asset must resolve locally as a PNG."""
for name in BRANDING_ASSETS:
url = f"/static/branding/{name}"
resp = await client.get(url)
assert resp.status_code == 200, f"{url} -> {resp.status_code}"
assert resp.headers.get("content-type", "").startswith("image/png"), f"{url}: {resp.headers.get('content-type')!r}"
assert len(resp.content) > 100, f"{url} looks empty"
async def test_csp_serves_branding_without_changes(client: AsyncClient):
"""img-src already allows same-origin PNGs — no external host needed."""
resp = await client.get("/login")
assert resp.status_code == 200
csp = resp.headers["content-security-policy"]
img_sources = _directive_sources(csp, "img-src")
assert img_sources, f"no img-src directive in CSP: {csp}"
assert "'self'" in img_sources and "data:" in img_sources and "blob:" in img_sources
assert "cdn." not in csp # fully self-contained, like the vendored scripts
+30
View File
@@ -72,3 +72,33 @@ async def test_csp_no_longer_allows_cdn_hosts(client: AsyncClient):
assert "script-src 'self' 'unsafe-inline'" in csp
assert "style-src 'self' 'unsafe-inline'" in csp
assert "connect-src 'self'" in csp
def _directive_sources(csp: str, directive: str) -> list[str]:
"""Return the source list of one CSP directive (e.g. ``script-src``)."""
for part in csp.split(";"):
tokens = part.split()
if tokens and tokens[0].strip() == directive:
return [t.strip() for t in tokens[1:]]
return []
async def test_csp_script_src_allows_unsafe_eval_for_alpine(client: AsyncClient):
"""/login CSP must permit 'unsafe-eval' in script-src (Alpine 3.17.2 runtime).
Alpine's expression evaluator compiles every ``x-data``/``x-show``/``x-text``
expression with ``new Function()``. A strict CSP without ``'unsafe-eval'``
blocks each evaluation ("Refused to evaluate a string as JavaScript ..."),
Alpine never initializes, and the loading overlay (``x-show="loading"`` in
base.html) stays visible forever — regression shipped with the P0 CSP.
"""
resp = await client.get("/login")
assert resp.status_code == 200
csp = resp.headers["content-security-policy"]
script_sources = _directive_sources(csp, "script-src")
assert script_sources, f"no script-src directive in CSP: {csp}"
assert "'unsafe-eval'" in script_sources, f"script-src missing 'unsafe-eval': {csp}"
# Everything else stays as hardened: still 'self'-only apart from the two
# Alpine-required relaxations, and connect-src remains 'self'.
assert "'self'" in script_sources
assert "connect-src 'self'" in csp
+37
View File
@@ -308,6 +308,43 @@ async def test_normalize_does_not_map_ambiguous_admin_alias(client: AsyncClient)
assert user.role == "admin"
async def test_underscore_legacy_aliases_normalize_to_canonical():
"""Open-register rows can carry underscore role forms ('cs_rep',
'cs_manager', 'fm_dispatcher') — the exact gap Mumuni relay #747 found on
user 18 (test@denya.com). Each must map onto its canonical role so startup
normalization can converge the row instead of stranding it on /tickets."""
from app.core.roles import normalize_role
assert normalize_role("cs_rep") == "CS Rep"
assert normalize_role("cs_manager") == "CS Manager"
assert normalize_role("fm_dispatcher") == "FM Dispatcher"
# Matching is case/whitespace tolerant, like the space-form aliases.
assert normalize_role(" CS_REP ") == "CS Rep"
assert normalize_role("cs_rep") is not None # known, not fail-closed
async def test_legacy_cs_rep_row_converges_and_authenticates(client: AsyncClient):
"""A 'cs_rep' row (user 18 test@denya.com shape) is converged to canonical
'CS Rep' by the startup self-heal and can log in again (no fail-closed
denial, and the frontend CS nav sees the canonical role)."""
await _insert_user("cs-rep-legacy@example.com", "cs_rep")
await _normalize_roles() # what lifespan does each boot
async with async_session_factory() as session:
from sqlalchemy import select
user = (
await session.execute(select(User).where(User.email == "cs-rep-legacy@example.com"))
).scalar_one()
assert user.role == "CS Rep"
login = await client.post(
"/api/auth/login", json={"email": "cs-rep-legacy@example.com", "password": "denya123"}
)
assert login.status_code == 200, login.text
me = await client.get("/api/auth/me", headers=_auth(login.json()["access_token"]))
assert me.json()["role"] == "CS Rep"
# ── P0 email normalization (legacy mixed-case rows) ───────────────────
async def test_legacy_mixed_case_email_migrated_and_authenticates(client: AsyncClient):
"""A legacy row whose email was stored verbatim in mixed case (the old open
+287
View File
@@ -0,0 +1,287 @@
"""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 data sources
the card consumes must expose the technician's real name via
``assigned_technician_name``, never an id 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
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
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. Real technician names on the workload data paths ─────────────
async def test_workload_sources_report_real_technician_names(client):
"""The FM workload card reads ``/api/tickets?page_size=200`` and the
performance report reads ``/api/tickets/tech-performance``. For an assigned
ticket both must expose the technician's real full name, never a
``Tech #<id>`` placeholder.
"""
prosper = await _user_id("prosper@denya.com")
await _seed_tickets([_ticket("In Progress", prosper), _ticket("Escalated", prosper)])
token = await _login(client)
list_resp = await client.get("/api/tickets?page_size=200", headers=_auth(token))
assert list_resp.status_code == 200
active = [
t for t in list_resp.json()["items"]
if t["status"] not in ("Closed", "Completed", "Cancelled")
]
# Same mapping the FM workload card builds from active assigned tickets.
workload: dict[int, str] = {}
for t in active:
if t["assigned_to"]:
workload.setdefault(t["assigned_to"], t["assigned_technician_name"] or "Unassigned")
assert workload == {prosper: "Prosper"}
perf_resp = await client.get("/api/tickets/tech-performance", headers=_auth(token))
assert perf_resp.status_code == 200
rows = {t["technician_id"]: t for t in perf_resp.json()["technicians"]}
assert rows[prosper]["name"] == "Prosper"
assert all(not row["name"].startswith("Tech #") for row in rows.values())
# ── 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
+164
View File
@@ -0,0 +1,164 @@
"""WhatsApp demo-number wiring (WHATSAPP_DEMO_TO).
The demo round trip (webhook POST -> ticket -> auto-reply -> mock-log) surfaces
the expected sender/recipient number. That number is **never committed**: it
lives only in the deploy host's .env and reaches the app through the
``WHATSAPP_DEMO_TO`` setting (same fail-closed env pattern as the webhook
secret). ``build_demo_webhook_payload()`` builds the demo payload from the
setting so mock-log and the auto-reply show the configured number; with the
setting unset it raises instead of fabricating a sender.
Anchors:
* ``settings.WHATSAPP_DEMO_TO`` defaults to ``""`` and no tracked file assigns
it a value (real numbers stay out of git history).
* ``build_demo_webhook_payload`` uses the configured number as the message
``from`` and fails closed when unset.
* A full round trip with the secret + demo number logs the number and surfaces
it in ``/api/whatsapp/mock-log``; the auto-reply targets the same number.
* Webhook stays 403 without the secret and mock-log stays 401 without auth.
"""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
import pytest
pytestmark = pytest.mark.asyncio
from app.core.config import settings # noqa: E402
# Clearly-fake test number — never use a real contact number in source.
_FAKE_DEMO_TO = "+233559999999"
async def _login(client, email="wahab@denya.com", password="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:
return {"Authorization": f"Bearer {token}"}
async def _capture_reply(monkeypatch, calls: list):
"""Swap the Meta client for a recorder; returns the fake."""
from app.routers import whatsapp as whatsapp_router
from app.schemas.whatsapp import WhatsAppReplyResponse
async def _fake_reply(to_phone: str, text: str):
calls.append((to_phone, text))
return WhatsAppReplyResponse(success=True, message="sent")
monkeypatch.setattr(whatsapp_router, "send_whatsapp_reply", _fake_reply)
return whatsapp_router
# ── Config plumbing ───────────────────────────────────────────────────
def test_demo_number_defaults_empty_and_never_committed():
"""WHATSAPP_DEMO_TO must default empty; no tracked file may set a value.
Real WhatsApp numbers are deploy-host .env secrets — a committed value
(even in tests or .env.example) would leak into git history.
"""
assert settings.WHATSAPP_DEMO_TO == ""
root = Path.cwd()
listed = subprocess.run(
["git", "ls-files", "-z"], cwd=root, capture_output=True, text=True
)
assert listed.returncode == 0, "git ls-files failed inside the test repo"
tracked = [p for p in listed.stdout.split("\0") if p]
assignment = re.compile(r"^WHATSAPP_DEMO_TO[ \t]*=[ \t]*(\S*)$")
offenders = []
for rel in tracked:
path = root / rel
if path.suffix.lower() in {".png", ".jpg", ".jpeg", ".db", ".pyc", ".ico", ".woff", ".woff2", ".gz"}:
continue # binaries cannot carry a text assignment
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
for lineno, line in enumerate(text.splitlines(), start=1):
match = assignment.match(line)
if match and match.group(1):
offenders.append(f"{rel}:{lineno}: WHATSAPP_DEMO_TO={match.group(1)!r}")
assert not offenders, (
"WHATSAPP_DEMO_TO must stay unset in tracked files (set it in the "
f"deploy host .env only); found: {offenders}"
)
# ── Demo payload builder ─────────────────────────────────────────────
def test_demo_payload_builder_fails_closed_when_unset(monkeypatch):
"""Without WHATSAPP_DEMO_TO the builder raises rather than fabricating."""
from app.routers.whatsapp import build_demo_webhook_payload
monkeypatch.setattr(settings, "WHATSAPP_DEMO_TO", "")
with pytest.raises(RuntimeError, match="WHATSAPP_DEMO_TO"):
build_demo_webhook_payload()
def test_demo_payload_builder_uses_configured_number(monkeypatch):
"""The demo payload's message ``from`` is the configured demo number."""
from app.routers.whatsapp import build_demo_webhook_payload
monkeypatch.setattr(settings, "WHATSAPP_DEMO_TO", _FAKE_DEMO_TO)
payload = build_demo_webhook_payload(text="Leaking tap", wa_message_id="wamid.demo.42")
entry = payload["entry"][0]["changes"][0]
assert entry["message"]["from"] == _FAKE_DEMO_TO
assert entry["message"]["id"] == "wamid.demo.42"
assert entry["message"]["text"]["text"] == "Leaking tap"
# ── Demo round trip ──────────────────────────────────────────────────
async def test_demo_round_trip_surfaces_number_in_mock_log(client, monkeypatch):
"""Webhook demo payload -> ticket + log; mock-log shows the demo number."""
from app.routers.whatsapp import build_demo_webhook_payload
replies: list[tuple[str, str]] = []
await _capture_reply(monkeypatch, replies)
monkeypatch.setattr(settings, "WHATSAPP_WEBHOOK_SECRET", "test-webhook-secret")
monkeypatch.setattr(settings, "WHATSAPP_DEMO_TO", _FAKE_DEMO_TO)
resp = await client.post(
"/api/whatsapp/webhook",
json=build_demo_webhook_payload(text="Demo leak"),
headers={"X-Webhook-Secret": "test-webhook-secret"},
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["status"] == "processed"
assert data["ticket_number"].startswith("PAV-")
# Auto-reply went back to the demo number.
assert replies and replies[0][0] == _FAKE_DEMO_TO, replies
token = await _login(client)
log = await client.get("/api/whatsapp/mock-log", headers=_auth(token))
assert log.status_code == 200, log.text
entries = log.json()
assert any(e["from_number"] == _FAKE_DEMO_TO for e in entries)
assert any(e["ticket_number"] == data["ticket_number"] for e in entries)
async def test_webhook_still_403_without_secret_even_with_demo_number(client, monkeypatch):
"""The webhook secret gate is independent of the demo number."""
monkeypatch.setattr(settings, "WHATSAPP_WEBHOOK_SECRET", "test-webhook-secret")
monkeypatch.setattr(settings, "WHATSAPP_DEMO_TO", _FAKE_DEMO_TO)
resp = await client.post("/api/whatsapp/webhook", json={"object": "whatsapp_business_account"})
assert resp.status_code == 403
async def test_mock_log_still_401_gated(client):
"""mock-log stays authenticated-only (no token -> 401)."""
resp = await client.get("/api/whatsapp/mock-log")
assert resp.status_code == 401, resp.text
+144
View File
@@ -0,0 +1,144 @@
"""Regression: legacy ``whatsapp_log`` tables self-heal at startup.
Producer/consumer for the CT115 defect (Mumuni relay #747): the live demo DB's
``whatsapp_log`` table still has the pre-webhook shape — ``command`` instead of
``message_text`` and no ``wa_message_id``/``ticket_number`` — so
``GET /api/whatsapp/mock-log`` 500'd with ``OperationalError: no such column:
whatsapp_log.message_text`` even for a valid admin token.
Producer: build the legacy-shaped table (as the live DB holds it) and seed it
with ``command`` rows.
Consumer: boot the app's startup self-heal (``ensure_legacy_schema``), then read
back through the authenticated mock-log endpoint, insert through the ORM write
path, and re-run the self-heal to prove idempotency.
"""
from __future__ import annotations
from datetime import datetime
import pytest
from sqlalchemy import text
from app.core.database import async_session_factory, engine
from app.main import ensure_legacy_schema
pytestmark = pytest.mark.asyncio
# Pre-webhook shape (commit 4afdc36 changed the model but no migration shipped):
# id, command (NOT NULL), from_number, ticket_id, received_at.
LEGACY_WHATSAPP_LOG_DDL = (
"CREATE TABLE whatsapp_log ("
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "
"command TEXT NOT NULL, "
"from_number VARCHAR(50), "
"ticket_id INTEGER, "
"received_at DATETIME NOT NULL)"
)
EXPECTED_MODEL_COLUMNS = {
"id",
"from_number",
"message_text",
"wa_message_id",
"ticket_id",
"ticket_number",
"received_at",
}
async def _login(client, email="wahab@denya.com", password="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 _replace_with_legacy_whatsapp_log() -> None:
"""Drop the model-shaped table and recreate the legacy shape with rows."""
async with engine.begin() as conn:
await conn.execute(text("DROP TABLE IF EXISTS whatsapp_log"))
await conn.execute(text(LEGACY_WHATSAPP_LOG_DDL))
await conn.execute(
text(
"INSERT INTO whatsapp_log (command, from_number, ticket_id, received_at) VALUES "
"('legacy older message', '+233200000001', 1, '2026-09-08 08:00:00'), "
"('legacy newest message', '+233200000002', 2, '2026-09-09 09:30:00')"
)
)
async def _columns() -> set[str]:
async with engine.begin() as conn:
result = await conn.execute(text("PRAGMA table_info(whatsapp_log)"))
return {row[1] for row in result}
async def test_legacy_whatsapp_log_self_heals_and_mock_log_200(client):
"""Producer: legacy whatsapp_log (command shape). Consumer: startup self-heal
then authenticated mock-log — the exact 500 from the live instance."""
token = await _login(client)
await _replace_with_legacy_whatsapp_log()
# Pre-fix reproduction: on the legacy table this endpoint fails exactly as
# reported (production: HTTP 500; under ASGITransport the app never returns
# a response so the sqlalchemy OperationalError propagates).
import sqlalchemy.exc
with pytest.raises(sqlalchemy.exc.OperationalError):
await client.get("/api/whatsapp/mock-log", headers=_auth(token))
# ── Boot the startup self-heal (what lifespan does each boot) ──
async with engine.begin() as conn:
await ensure_legacy_schema(conn)
columns = await _columns()
assert EXPECTED_MODEL_COLUMNS <= columns
assert "command" not in columns # obsolete model-dropped column is gone
# Authenticated mock-log returns 200 and the backfilled rows are readable.
resp = await client.get("/api/whatsapp/mock-log", headers=_auth(token))
assert resp.status_code == 200, resp.text
entries = resp.json()
assert [e["message_text"] for e in entries] == [
"legacy newest message",
"legacy older message",
] # order: received_at desc; bodies preserved from legacy `command`
# ORM write path works on the healed table (the current app inserts
# message_text/wa_message_id and never writes `command`).
from app.models.whatsapp_log import WhatsAppLog
async with async_session_factory() as session:
session.add(
WhatsAppLog(
from_number="+233200000003",
message_text="inbound after self-heal",
wa_message_id="wamid.healed.1",
ticket_id=None,
ticket_number=None,
received_at=datetime(2026, 9, 10, 10, 0, 0),
)
)
await session.commit()
resp = await client.get("/api/whatsapp/mock-log", headers=_auth(token))
assert resp.status_code == 200, resp.text
entries = resp.json()
assert entries[0]["message_text"] == "inbound after self-heal"
assert entries[0]["from_number"] == "+233200000003"
assert len(entries) == 3
# ── Idempotent on the next boot ──
async with engine.begin() as conn:
await ensure_legacy_schema(conn)
columns = await _columns()
assert EXPECTED_MODEL_COLUMNS <= columns
assert "command" not in columns
resp = await client.get("/api/whatsapp/mock-log", headers=_auth(token))
assert resp.status_code == 200, resp.text
assert len(resp.json()) == 3