Compare commits

...
Author SHA1 Message Date
Mumuni (Hermes) 106699ac5e feat: apply Wahab Abdul's 2026-09-28 directives (roster, sub-contractors, penthouses)
Three client decisions for Denya OneCare / Pavilion Accra:

1. Technician roster converges to exactly 5 named techs
   - Samuel Shang, Desmond Afful, Desmond Odekyi, Francis Norgbey, Nicholas Nartey
   - New app/services/roster.py: converge_tech_roster() runs at startup and is
     idempotent; off-roster techs are DEACTIVATED, never deleted, so ticket
     history keeps a valid assignee reference
   - seed.py SEED_USERS_DATA updated; placeholder emails until client confirms

2. Sub-contractors appear in the "Assign to" list alongside technicians
   - New canonical role "Sub-contractor" (ASSIGNEE_POOL_ROLES = Tech + Sub-contractor)
   - New GET /api/auth/assignees endpoint returns active pool members only
   - Server-side _validate_assignee gate in ticket service rejects off-pool
     or deactivated assignees (400/404)
   - "Assign To" dropdown added to the new-ticket form; assigning at creation
     auto-advances Logged -> Assigned
   - base.html isTech() includes Sub-contractor (portal UX, tracked "under tech")

3. Penthouse units selectable when raising a ticket
   - apartment_mapping.json: PH1E-/PH1W-/PH2E-/PH2W- -> clean codes
   - seed_units self-heals legacy malformed codes on existing DBs and sets floors
   - Penthouse units added to the built-in fallback seed

Tests: new tests/test_wahab_directives_20260928.py (8 tests); updated the
stale East unit count in test_categories_and_units.py (60 -> 62 with penthouses).
Full suite green.
2026-09-28 21:42:56 +00:00
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
33 changed files with 1616 additions and 37 deletions
+7
View File
@@ -20,6 +20,13 @@ META_GRAPH_BASE=https://graph.facebook.com/v18.0
# Generate with: openssl rand -hex 32 # Generate with: openssl rand -hex 32
WHATSAPP_WEBHOOK_SECRET= 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) ──────────────────────────── # ── Login rate limiting (P0) ────────────────────────────
# ~5 failed login attempts per 15 minutes per IP+email → HTTP 429 # ~5 failed login attempts per 15 minutes per IP+email → HTTP 429
LOGIN_RATE_LIMIT_MAX_ATTEMPTS=5 LOGIN_RATE_LIMIT_MAX_ATTEMPTS=5
+35
View File
@@ -73,6 +73,15 @@ read/write 500s. `ensure_legacy_schema` (app/main.py) adds the missing columns
and backfills+drops the obsolete NOT NULL `command` column idempotently at and backfills+drops the obsolete NOT NULL `command` column idempotently at
startup — do not hand-edit legacy DBs, ship a self-heal there instead. 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/` ### Pages (Sprint 3) — Jinja2 templates at `app/templates/`
| Method | Path | Auth | Description | | Method | Path | Auth | Description |
|--------|------|------|-------------| |--------|------|------|-------------|
@@ -80,6 +89,7 @@ startup — do not hand-edit legacy DBs, ship a self-heal there instead.
| GET | `/dashboard/cs` | Client | CS dashboard | | GET | `/dashboard/cs` | Client | CS dashboard |
| GET | `/dashboard/fm` | Client | FM dashboard | | GET | `/dashboard/fm` | Client | FM dashboard |
| GET | `/dashboard/ceo` | Client | CEO 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` | Client | All Issues filterable table |
| GET | `/tickets/new` | Client | Create Issue form | | GET | `/tickets/new` | Client | Create Issue form |
| GET | `/tickets/{id}` | Client | Issue detail with timeline | | GET | `/tickets/{id}` | Client | Issue detail with timeline |
@@ -87,6 +97,20 @@ startup — do not hand-edit legacy DBs, ship a self-heal there instead.
Frontend: Alpine.js + Tailwind CSS vendored same-origin (no CDN) — see 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`. "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) ### Frontend assets (vendored, LAN-safe)
- Alpine.js 3.17.2 + Tailwind Play 3.4.17 are committed under `app/static/vendor/` - 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 and served at `/static/vendor/…` (mounted in `app/main.py`, versioned
@@ -108,6 +132,17 @@ Frontend: Alpine.js + Tailwind CSS vendored same-origin (no CDN) — see
`test_csp_script_src_allows_unsafe_eval_for_alpine` in `test_csp_script_src_allows_unsafe_eval_for_alpine` in
`tests/test_frontend_vendoring.py`. `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) ### Tickets (Sprint 2)
| Method | Path | Auth | Description | | Method | Path | Auth | Description |
|--------|------|------|-------------| |--------|------|------|-------------|
+4 -4
View File
@@ -807,25 +807,25 @@
"floor": 7 "floor": 7
}, },
{ {
"apartment_code": "PH1E-", "apartment_code": "PH1E",
"property": "East", "property": "East",
"building": "Pavilion East", "building": "Pavilion East",
"floor": null "floor": null
}, },
{ {
"apartment_code": "PH1W-", "apartment_code": "PH1W",
"property": "West", "property": "West",
"building": "Pavilion West", "building": "Pavilion West",
"floor": null "floor": null
}, },
{ {
"apartment_code": "PH2E-", "apartment_code": "PH2E",
"property": "East", "property": "East",
"building": "Pavilion East", "building": "Pavilion East",
"floor": null "floor": null
}, },
{ {
"apartment_code": "PH2W-", "apartment_code": "PH2W",
"property": "West", "property": "West",
"building": "Pavilion West", "building": "Pavilion West",
"floor": null "floor": null
+8
View File
@@ -39,6 +39,14 @@ class Settings(BaseSettings):
# Shared secret for inbound webhook POSTs (header ``X-Webhook-Secret``). # Shared secret for inbound webhook POSTs (header ``X-Webhook-Secret``).
# Fail-closed: when unset/empty the webhook rejects every message. # Fail-closed: when unset/empty the webhook rejects every message.
WHATSAPP_WEBHOOK_SECRET: str = "" 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 limiting ──────────────────────────────────────────
LOGIN_RATE_LIMIT_MAX_ATTEMPTS: int = 5 LOGIN_RATE_LIMIT_MAX_ATTEMPTS: int = 5
+17
View File
@@ -35,6 +35,7 @@ CANONICAL_ROLES: tuple[str, ...] = (
"CS Manager", "CS Manager",
"FM Dispatcher", "FM Dispatcher",
"Tech", "Tech",
"Sub-contractor",
"CEO", "CEO",
"Director", "Director",
) )
@@ -45,6 +46,18 @@ ADMIN_ROLES: tuple[str, ...] = ("Admin/Jerome", "Admin/Wahab")
# Roles shown to the frontend nav/assignment helpers as "technician" pool. # Roles shown to the frontend nav/assignment helpers as "technician" pool.
TECHNICIAN_ROLE = "Tech" TECHNICIAN_ROLE = "Tech"
# External subcontractor labour (client directive 2026-09: Wahab). Sub-contractors
# are assignable work resources tracked "under tech" — the FM coordinator owns
# their tickets — so they must surface in the Assign-to picker next to Techs.
SUBCONTRACTOR_ROLE = "Sub-contractor"
# The exact roles the assignment pickers offer (active users only).
# Client directive 2026-09-28: sub-contractors appear in the "Assign to"
# list alongside the technicians — external labour tracked under the FM
# coordinator, so they are a sub-tier of the Tech pool, not a separate
# workflow lane.
ASSIGNEE_POOL_ROLES: tuple[str, ...] = (TECHNICIAN_ROLE, SUBCONTRACTOR_ROLE)
# ── Legacy alias → canonical mapping (case-insensitive) ─────────────── # ── Legacy alias → canonical mapping (case-insensitive) ───────────────
# Keys are lowercased. Unambiguous nicknames from legacy/early seeds and the # Keys are lowercased. Unambiguous nicknames from legacy/early seeds and the
# brief's role model ("technician/cs/fm/ceo") converge onto canonical roles. # brief's role model ("technician/cs/fm/ceo") converge onto canonical roles.
@@ -60,6 +73,10 @@ ROLE_ALIASES: dict[str, str] = {
"fm": "FM Dispatcher", "fm": "FM Dispatcher",
"fm dispatcher": "FM Dispatcher", "fm dispatcher": "FM Dispatcher",
"fm_dispatcher": "FM Dispatcher", "fm_dispatcher": "FM Dispatcher",
"sub-contractor": SUBCONTRACTOR_ROLE,
"sub contractor": SUBCONTRACTOR_ROLE,
"subcontractor": SUBCONTRACTOR_ROLE,
"sub_contractor": SUBCONTRACTOR_ROLE,
"ceo": "CEO", "ceo": "CEO",
"director": "Director", "director": "Director",
} }
+4
View File
@@ -14,6 +14,7 @@ from sqlalchemy import text
from app.core.config import settings from app.core.config import settings
from app.core.database import Base, async_session_factory, engine from app.core.database import Base, async_session_factory, engine
from app.routers import auth, health, pages, tickets, whatsapp from app.routers import auth, health, pages, tickets, whatsapp
from app.services.roster import converge_tech_roster
from app.services.seed import ( from app.services.seed import (
normalize_legacy_user_emails, normalize_legacy_user_emails,
normalize_legacy_user_roles, normalize_legacy_user_roles,
@@ -118,6 +119,9 @@ async def lifespan(app: FastAPI):
# ``technician``/``cs``/``fm``) onto the canonical taxonomy at startup. # ``technician``/``cs``/``fm``) onto the canonical taxonomy at startup.
await normalize_legacy_user_roles(session) await normalize_legacy_user_roles(session)
await normalize_legacy_user_emails(session) await normalize_legacy_user_emails(session)
# Client directive 2026-09-28: the active Tech pool is exactly the
# five confirmed names; any other active Tech is deactivated here.
await converge_tech_roster(session)
await session.commit() await session.commit()
await seed_units(session, json_path=str(settings.BASE_DIR / "apartment_mapping.json")) await seed_units(session, json_path=str(settings.BASE_DIR / "apartment_mapping.json"))
await session.commit() await session.commit()
+20 -1
View File
@@ -14,7 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
from app.core.ratelimit import login_rate_limiter from app.core.ratelimit import login_rate_limiter
from app.core.roles import ADMIN_ROLES from app.core.roles import ADMIN_ROLES, ASSIGNEE_POOL_ROLES
from app.core.security import get_current_user, require_roles from app.core.security import get_current_user, require_roles
from app.models.user import User from app.models.user import User
from app.schemas.auth import ( from app.schemas.auth import (
@@ -88,6 +88,25 @@ async def list_users(
return list(result.scalars().all()) return list(result.scalars().all())
@router.get("/assignees", response_model=list[UserOut])
async def list_assignees(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> list[User]:
"""Active users selectable in the "Assign to" pickers.
The pool is the technician roster plus sub-contractors (client
directive 2026-09-28): external labour assigned directly, tracked
under the FM coordinator. Deactivated accounts never appear.
"""
result = await db.execute(
select(User)
.where(User.role.in_(ASSIGNEE_POOL_ROLES), User.active.is_(True))
.order_by(User.full_name)
)
return list(result.scalars().all())
# ── Admin user management ──────────────────────────────────────────── # ── Admin user management ────────────────────────────────────────────
@router.post("/users", response_model=UserOut, status_code=status.HTTP_201_CREATED) @router.post("/users", response_model=UserOut, status_code=status.HTTP_201_CREATED)
async def create_user( async def create_user(
+8
View File
@@ -33,6 +33,14 @@ async def ceo_dashboard(request: Request):
return templates.TemplateResponse(request, "dashboard/ceo.html") 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) @router.get("/tickets", response_class=HTMLResponse)
async def ticket_list(request: Request): async def ticket_list(request: Request):
return templates.TemplateResponse(request, "tickets/list.html") return templates.TemplateResponse(request, "tickets/list.html")
+17
View File
@@ -23,6 +23,7 @@ from app.schemas.ticket import (
CategoryOut, CategoryOut,
CategoryTreeOut, CategoryTreeOut,
SLAStatusOut, SLAStatusOut,
TechnicianPerformanceReportOut,
TicketBrief, TicketBrief,
TicketCreate, TicketCreate,
TicketListResponse, TicketListResponse,
@@ -248,6 +249,22 @@ async def list_tickets(
return TicketListResponse(items=items, total=total, page=page, page_size=effective_page_size) 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") @router.get("/{ticket_id}/transitions")
async def get_ticket_transitions( async def get_ticket_transitions(
ticket_id: int, 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 ────────────────────────────────────────── # ── Meta Graph API helpers ──────────────────────────────────────────
async def send_whatsapp_reply( async def send_whatsapp_reply(
to_phone: str, to_phone: str,
+54
View File
@@ -129,6 +129,60 @@ class TicketListResponse(BaseModel):
page_size: int page_size: int
# ── Technician performance ───────────────────────────────────────────
class TechnicianPerformanceOut(BaseModel):
"""Per-technician workload and outcome aggregate (by real name).
``open_tickets`` is ``total_assigned - completed - cancelled``;
``pending`` is the remainder bucket (statuses such as New/Logged/Triage/
Assigned plus verification stages) and keeps the named buckets summing to
``total_assigned``. ``avg_resolution_hours`` averages ``created_at ->
closed_at`` and is ``null`` when no finished task carries a timestamp —
``resolved_without_timestamps`` then says how many those are.
"""
technician_id: int
name: str
total_assigned: int
completed: int
closed: int
in_progress: int
escalated: int
cancelled: int
pending: int
open_tickets: int
completion_rate: float
avg_resolution_hours: float | None = None
resolved_with_timestamps: int
resolved_without_timestamps: int
status_breakdown: dict[str, int] = {}
class TechnicianPerformanceTotalsOut(BaseModel):
"""Fleet-wide roll-up of :class:`TechnicianPerformanceOut`."""
technicians: int
total_tickets: int
total_assigned: int
completed: int
closed: int
in_progress: int
escalated: int
cancelled: int
pending: int
open_tickets: int
completion_rate: float
avg_resolution_hours: float | None = None
resolved_with_timestamps: int
resolved_without_timestamps: int
unassigned_tickets: int
class TechnicianPerformanceReportOut(BaseModel):
"""Response for ``GET /api/tickets/tech-performance``."""
generated_at: datetime
technicians: list[TechnicianPerformanceOut]
totals: TechnicianPerformanceTotalsOut
# ── SLA ────────────────────────────────────────────────────────────── # ── SLA ──────────────────────────────────────────────────────────────
class SLAStatusOut(BaseModel): class SLAStatusOut(BaseModel):
priority: str | None = None priority: str | None = None
+82
View File
@@ -0,0 +1,82 @@
"""Client-confirmed technician roster enforcement (Pavilion Denya OneCare).
Implements the FM's (Wahab Abdul) directive of 2026-09-28:
"i want the system to have these 5 names and nothing else, so replace
the current names with the 5 i sent to you. I'll confirm their email
address to you."
The five are seeded directly (``SEED_USERS_DATA``); this module is the
startup self-heal that keeps a *live* database honest, following the same
idempotent pattern as ``normalize_legacy_user_roles``:
* The active Tech pool is EXACTLY the five confirmed accounts (keyed on the
roster emails). Any other active Tech — legacy nickname accounts like
Prosper/Sam/Steven/Junior, or a stale same-name account the seed has
replaced — is deactivated. Never deleted: ``tickets.assigned_to`` FKs and
ticket history must stay intact. Deactivation fails closed at login
(``auth.authenticate_user``) and drops the user from every picker.
* A second active account carrying a roster member's full name but a
different email is deactivated as a duplicate; the roster-email account
wins (it is what admins will manage once the client confirms emails).
* Roster accounts that exist but were deactivated get re-activated (the
client put the name back on the list).
Sub-contractors (role ``Sub-contractor``) are NOT part of this roster —
the client has not named them yet. They belong to the assignable pool
(``app.core.roles.ASSIGNEE_POOL_ROLES``) alongside Techs and will be seeded
as soon as names/emails arrive. Convergence never touches them.
"""
from __future__ import annotations
import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.roles import TECHNICIAN_ROLE
from app.models.user import User
from app.services.seed import CONFIRMED_TECH_EMAILS
logger = logging.getLogger(__name__)
_ROSTER_EMAILS = frozenset(CONFIRMED_TECH_EMAILS)
_ROSTER_NAMES = {
"samuel shang", "desmond afful", "desmond odekyi",
"francis norgbey", "nicholas nartey",
}
async def converge_tech_roster(session: AsyncSession) -> dict:
"""Enforce the client-confirmed 5-name active Tech pool (idempotent)."""
users = list((await session.execute(select(User))).scalars().all())
roster_by_email = {u.email: u for u in users if u.email in _ROSTER_EMAILS}
roster_ids = {u.id for u in roster_by_email.values()}
deactivated: list[str] = []
reactivated: list[str] = []
for u in users:
if u.role != TECHNICIAN_ROLE:
continue
name_key = u.full_name.strip().lower()
on_roster = u.email in _ROSTER_EMAILS
duplicate_name = (not on_roster) and name_key in _ROSTER_NAMES
if on_roster:
if not u.active:
u.active = True
reactivated.append(u.full_name)
elif u.active:
# Off-roster Tech, or a same-name shadow of a roster account:
# deactivate (never delete) so the picker shows exactly the five.
u.active = False
deactivated.append(
f"{u.full_name} <{u.email}>" + (" (duplicate name)" if duplicate_name else "")
)
stats = {"deactivated": deactivated, "reactivated": reactivated,
"roster_present": len(roster_ids)}
if deactivated or reactivated:
logger.info("Tech roster converged: %s", stats)
return stats
+70 -7
View File
@@ -20,7 +20,25 @@ from app.models.category import Category
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Penthouse (Pavilion pent-house) units. Client directive 2026-09-28
# (Wahab Abdul): "PHE1 & PHW2 means Pent House East/West, they must be
# selectable when raising a ticket. they should be added to the units".
# The mapping file shipped with dangling-dash codes (PH1E-…) and null floors;
# they are cleaned here and self-healed in any existing database.
_PENTHOUSE_FLOORS = {"PH1E": 1, "PH1W": 1, "PH2E": 2, "PH2W": 2}
_LEGACY_PENTHOUSE_RENAMES = {
"PH1E-": "PH1E", "PH1W-": "PH1W", "PH2E-": "PH2E", "PH2W-": "PH2W",
}
# ── Seed user data (dicts to avoid module-level model instantiation) ─ # ── Seed user data (dicts to avoid module-level model instantiation) ─
# TECH POOL (client directive 2026-09-28, Wahab Abdul — "these 5 names and
# nothing else"): Samuel Shang, Desmond Afful, Desmond Odekyi, Francis
# Norgbey, Nicholas Nartey. Emails are placeholders until the client confirms
# them; the picker shows full names, and login matching is by email only.
# The old nickname Techs (Prosper/Sam/Steven/Junior/Francis/Desmond/Afful)
# are removed from the seed; ``roster.converge_tech_roster`` deactivates any
# such account still present in a live database (never deletes — ticket
# history holds ``users.id`` FKs).
SEED_USERS_DATA = [ SEED_USERS_DATA = [
{"email": "jerome@denya.com", "full_name": "Jerome Tabiri", "phone": "+233000000001", "role": "Admin/Jerome"}, {"email": "jerome@denya.com", "full_name": "Jerome Tabiri", "phone": "+233000000001", "role": "Admin/Jerome"},
{"email": "wahab@denya.com", "full_name": "Wahab", "phone": "+233000000002", "role": "Admin/Wahab"}, {"email": "wahab@denya.com", "full_name": "Wahab", "phone": "+233000000002", "role": "Admin/Wahab"},
@@ -30,17 +48,24 @@ SEED_USERS_DATA = [
{"email": "ama@denya.com", "full_name": "Ama", "phone": "+233000000006", "role": "CS Rep"}, {"email": "ama@denya.com", "full_name": "Ama", "phone": "+233000000006", "role": "CS Rep"},
{"email": "nicholas@denya.com", "full_name": "Nicholas", "phone": "+233000000007", "role": "FM Dispatcher"}, {"email": "nicholas@denya.com", "full_name": "Nicholas", "phone": "+233000000007", "role": "FM Dispatcher"},
{"email": "collins@denya.com", "full_name": "Collins", "phone": "+233000000008", "role": "FM Dispatcher"}, {"email": "collins@denya.com", "full_name": "Collins", "phone": "+233000000008", "role": "FM Dispatcher"},
{"email": "prosper@denya.com", "full_name": "Prosper", "phone": "+233000000010", "role": "Tech"}, {"email": "samuel.shang@denya.com", "full_name": "Samuel Shang", "phone": "+233000000010", "role": "Tech"},
{"email": "sam@denya.com", "full_name": "Sam", "phone": "+233000000011", "role": "Tech"}, {"email": "desmond.afful@denya.com", "full_name": "Desmond Afful", "phone": "+233000000015", "role": "Tech"},
{"email": "steven@denya.com", "full_name": "Steven", "phone": "+233000000012", "role": "Tech"}, {"email": "desmond.odekyi@denya.com", "full_name": "Desmond Odekyi", "phone": "+233000000011", "role": "Tech"},
{"email": "junior@denya.com", "full_name": "Junior (Samuel)", "phone": "+233000000013", "role": "Tech"}, {"email": "francis.norgbey@denya.com", "full_name": "Francis Norgbey", "phone": "+233000000014", "role": "Tech"},
{"email": "francis@denya.com", "full_name": "Francis", "phone": "+233000000014", "role": "Tech"}, {"email": "nicholas.nartey@denya.com", "full_name": "Nicholas Nartey", "phone": "+233000000013", "role": "Tech"},
{"email": "desmond@denya.com", "full_name": "Desmond Afful", "phone": "+233000000015", "role": "Tech"},
{"email": "afful@denya.com", "full_name": "Afful", "phone": "+233000000016", "role": "Tech"},
{"email": "scott@denya.com", "full_name": "Scott Murray", "phone": "+233000000020", "role": "CEO"}, {"email": "scott@denya.com", "full_name": "Scott Murray", "phone": "+233000000020", "role": "CEO"},
{"email": "director@denya.com", "full_name": "Director", "phone": "+233000000021", "role": "Director"}, {"email": "director@denya.com", "full_name": "Director", "phone": "+233000000021", "role": "Director"},
] ]
# The confirmed Tech pool, single source of truth for roster convergence.
CONFIRMED_TECH_EMAILS = [
"samuel.shang@denya.com",
"desmond.afful@denya.com",
"desmond.odekyi@denya.com",
"francis.norgbey@denya.com",
"nicholas.nartey@denya.com",
]
async def normalize_legacy_user_roles(db: AsyncSession) -> int: async def normalize_legacy_user_roles(db: AsyncSession) -> int:
"""Converge legacy role strings onto the unified canonical taxonomy. """Converge legacy role strings onto the unified canonical taxonomy.
@@ -178,6 +203,15 @@ async def seed_units(db: AsyncSession, json_path: str | Path | None = None) -> l
"building": f"Pavilion {wing}", "building": f"Pavilion {wing}",
"floor": floor, "floor": floor,
}) })
# Penthouse units (client directive 2026-09-28: must be
# selectable when raising a ticket)
for level in (1, 2):
units_data.append({
"apartment_code": f"PH{level}{wing[0]}",
"property": wing,
"building": f"Pavilion {wing}",
"floor": _PENTHOUSE_FLOORS[f"PH{level}{wing[0]}"],
})
created: list[Unit] = [] created: list[Unit] = []
for entry in units_data: for entry in units_data:
@@ -194,6 +228,35 @@ async def seed_units(db: AsyncSession, json_path: str | Path | None = None) -> l
) )
db.add(unit) db.add(unit)
created.append(unit) created.append(unit)
# Self-heal malformed penthouse codes from earlier mappings
# (``PH1E-`` with a dangling dash). Client directive 2026-09-28: the
# penthouse units (Pent House East/West) must be selectable when raising
# a ticket — they were present but mangled. Rename in place so any
# ``tickets.unit_id`` FKs stay intact; only rename when the clean code
# is free, and deactivate (never delete) a row that would collide.
renamed = 0
for legacy_code, clean_code in _LEGACY_PENTHOUSE_RENAMES.items():
legacy_row = (
await db.execute(select(Unit).where(Unit.apartment_code == legacy_code))
).scalar_one_or_none()
if legacy_row is None or legacy_row.apartment_code == clean_code:
continue
clash = (
await db.execute(select(Unit).where(Unit.apartment_code == clean_code))
).scalar_one_or_none()
if clash is not None:
# A clean-code row already exists; leave the legacy row as an
# inert historical alias (its tickets keep pointing at it).
continue
legacy_row.apartment_code = clean_code
if legacy_row.floor is None:
legacy_row.floor = _PENTHOUSE_FLOORS.get(clean_code)
renamed += 1
if renamed:
await db.flush()
logger.info("Repaired %d legacy penthouse unit codes", renamed)
if created: if created:
await db.flush() await db.flush()
for u in created: for u in created:
+200
View File
@@ -10,6 +10,7 @@ from sqlalchemy import delete as sa_delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.core.roles import ASSIGNEE_POOL_ROLES
from app.models.ticket import Escalation, Ticket, TicketPhoto, TicketTimeline from app.models.ticket import Escalation, Ticket, TicketPhoto, TicketTimeline
from app.models.unit import Unit from app.models.unit import Unit
from app.models.user import User from app.models.user import User
@@ -105,6 +106,26 @@ async def _get_ticket_or_404(db: AsyncSession, ticket_id: int) -> Ticket:
# ── CRUD ───────────────────────────────────────────────────────────── # ── CRUD ─────────────────────────────────────────────────────────────
async def _validate_assignee(db: AsyncSession, assigned_to: int | None) -> None:
"""Assignment must land on an active member of the pool (Tech roster +
sub-contractors, client directive 2026-09-28). Deactivated accounts —
replaced technicians included — can no longer receive new work."""
if assigned_to is None:
return
user = (
await db.execute(select(User).where(User.id == assigned_to))
).scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail="Assignee not found")
if not user.active or user.role not in ASSIGNEE_POOL_ROLES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"{user.full_name} is not in the assignable pool "
f"({'/'.join(ASSIGNEE_POOL_ROLES)}, active only)",
)
async def create_ticket( async def create_ticket(
db: AsyncSession, db: AsyncSession,
data: dict[str, Any], data: dict[str, Any],
@@ -118,6 +139,7 @@ async def create_ticket(
# when omitted the ticket is considered reported right now. The SLA clock # when omitted the ticket is considered reported right now. The SLA clock
# is unchanged — deadlines run from creation time, not the reported date. # is unchanged — deadlines run from creation time, not the reported date.
reported_at = data.get("reported_at") or datetime.now(timezone.utc) reported_at = data.get("reported_at") or datetime.now(timezone.utc)
await _validate_assignee(db, data.get("assigned_to"))
ticket = Ticket( ticket = Ticket(
ticket_number=ticket_number, ticket_number=ticket_number,
@@ -158,6 +180,20 @@ async def create_ticket(
user_id=user.id if user else None, user_id=user.id if user else None,
) )
# Assigning at creation advances Logged → Assigned (same rule as PATCH
# /tickets/{id} — client directive 2026-09-28 added the "Assign to"
# picker on the new-ticket form).
if ticket.assigned_to:
ticket.status = "Assigned"
await _log_status_change(
db,
ticket.id,
from_status="Logged",
to_status="Assigned",
note="Assigned at creation",
user_id=user.id if user else None,
)
await db.flush() await db.flush()
await db.refresh(ticket) await db.refresh(ticket)
return ticket return ticket
@@ -237,6 +273,7 @@ async def update_ticket(
) -> Ticket: ) -> Ticket:
"""Update a ticket. Status changes are validated and logged.""" """Update a ticket. Status changes are validated and logged."""
ticket = await _get_ticket_or_404(db, ticket_id) ticket = await _get_ticket_or_404(db, ticket_id)
await _validate_assignee(db, data.get("assigned_to"))
# Handle status transitions separately # Handle status transitions separately
new_status = data.get("status") new_status = data.get("status")
@@ -345,6 +382,169 @@ async def update_ticket(
return 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: async def delete_ticket(db: AsyncSession, ticket_id: int) -> Ticket:
"""Delete a ticket and all dependent rows (timeline, photos, escalations). """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

+23 -9
View File
@@ -4,6 +4,9 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Denya OneCare</title> <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 --> <!-- 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/alpine-3.17.2.min.js" defer></script>
<script src="/static/vendor/tailwind-3.4.17.js"></script> <script src="/static/vendor/tailwind-3.4.17.js"></script>
@@ -68,15 +71,11 @@
<!-- Left side --> <!-- Left side -->
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<a href="/dashboard/cs" class="flex items-center space-x-3"> <a href="/dashboard/cs" class="flex items-center space-x-3">
<!-- Denya Developers Logo Mark --> <!-- Denya Developers logo (same-origin app/static/branding) -->
<div class="w-9 h-9 bg-gold rounded-lg flex items-center justify-center shadow-sm"> <img src="/static/branding/denya-logo-h48.png" alt="Denya Developers"
<svg class="w-5 h-5 text-[#0d2b18]" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"> class="h-9 w-auto bg-white rounded-md px-1.5 py-1 shadow-sm">
<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>
<div class="flex flex-col"> <div class="flex flex-col">
<span class="text-white font-bold text-base leading-tight">Denya Developers</span> <span class="text-gold text-sm leading-tight font-bold">OneCare</span>
<span class="text-gold text-xs leading-tight font-medium">OneCare</span>
</div> </div>
</a> </a>
<!-- Nav Links --> <!-- Nav Links -->
@@ -90,6 +89,7 @@
<template x-if="isFM"> <template x-if="isFM">
<div class="hidden md:flex space-x-1 ml-6"> <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/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" 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> <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> </div>
@@ -97,6 +97,7 @@
<template x-if="isExecutive"> <template x-if="isExecutive">
<div class="hidden md:flex space-x-1 ml-6"> <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/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> <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> </div>
</template> </template>
@@ -114,16 +115,25 @@
<!-- Mobile Nav --> <!-- Mobile Nav -->
<div class="md:hidden border-b bg-[#0d2b18] border-denya-800" x-show="isLoggedIn" x-cloak> <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"> <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="/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" 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> <a href="/tickets/new" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-300">New Issue</a>
</div> </div>
</template> </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"> <template x-if="isExecutive">
<div class="flex overflow-x-auto px-4 py-2 space-x-2"> <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/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> <a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-300">Issues</a>
</div> </div>
</template> </template>
@@ -173,6 +183,10 @@
// Role helpers // Role helpers
get isCS() { return ['CS Rep', 'CS Manager'].includes(this.user.role) }, get isCS() { return ['CS Rep', 'CS Manager'].includes(this.user.role) },
get isFM() { return ['FM Dispatcher', 'Admin/Jerome', 'Admin/Wahab'].includes(this.user.role) }, get isFM() { return ['FM Dispatcher', 'Admin/Jerome', 'Admin/Wahab'].includes(this.user.role) },
// Sub-contractors are external labour tracked under the FM
// coordinator — client directive 2026-09-28 puts them in the
// Tech pool ("under tech"), so they share the technician UX.
get isTech() { return ['Tech', 'Sub-contractor'].includes(this.user.role) },
get isExecutive() { return ['CEO', 'Director'].includes(this.user.role) }, get isExecutive() { return ['CEO', 'Director'].includes(this.user.role) },
get isAdmin() { return ['Admin/Jerome', 'Admin/Wahab'].includes(this.user.role) }, get isAdmin() { return ['Admin/Jerome', 'Admin/Wahab'].includes(this.user.role) },
+6 -3
View File
@@ -1,9 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div x-data="ceoDashboard()" x-init="init()"> <div x-data="ceoDashboard()" x-init="init()">
<div class="mb-6"> <div class="mb-6 flex flex-wrap items-start justify-between gap-3">
<h1 class="text-2xl font-bold text-gray-900">Executive Dashboard</h1> <div>
<p class="text-gray-500 mt-1">Read-only strategic overview</p> <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> </div>
<!-- Executive KPI Cards --> <!-- 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"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- Tech Workload --> <!-- Tech Workload -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5"> <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"> <div class="space-y-3">
<template x-for="tech in techWorkload" :key="tech.id"> <template x-for="tech in techWorkload" :key="tech.id">
<div class="flex items-center justify-between p-2 hover:bg-gray-50 rounded"> <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; this.kpi.westJobs = active.filter(t => t.unit_id && unitMap[t.unit_id] === 'West').length;
} catch (e) { console.error('Property stats error', e); } } 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 = {}; const techMap = {};
active.forEach(t => { active.forEach(t => {
if (t.assigned_to) { 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++; 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="w-full max-w-md" x-data="loginForm()">
<div class="bg-white rounded-2xl shadow-lg p-8"> <div class="bg-white rounded-2xl shadow-lg p-8">
<div class="text-center mb-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"> <!-- Denya Developers full lockup (same-origin app/static/branding) -->
<svg class="w-8 h-8 text-denya-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <img src="/static/branding/denya-logo-h96.png" alt="Denya Developers"
<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"/> class="mx-auto mb-4 h-24 w-auto">
</svg>
</div>
<h1 class="text-2xl font-bold text-gray-900">Denya OneCare</h1> <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> <p class="text-gray-500 mt-1">Sign in to your dashboard</p>
</div> </div>
+5 -3
View File
@@ -342,9 +342,11 @@
async loadTechnicians() { async loadTechnicians() {
try { try {
const users = await app().apiGet('/api/auth/users'); // Assignable pool: active Techs + sub-contractors
// Assign dropdown should only offer Tech-role staff // (client directive 2026-09-28). Deactivated, replaced
this.technicians = (users || []).filter(u => u.role === 'Tech'); // technicians are excluded server-side.
const users = await app().apiGet('/api/auth/assignees');
this.technicians = users || [];
} catch (e) { } catch (e) {
console.error('Technicians load error', e); console.error('Technicians load error', e);
this.technicians = []; this.technicians = [];
+28 -1
View File
@@ -154,6 +154,20 @@
</div> </div>
</div> </div>
<!-- Assign to (optional at creation; Tech roster + sub-contractors) -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Assign To</label>
<select x-model="form.assigned_to" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none">
<option value="">Unassigned (dispatch later)</option>
<template x-for="tech in technicians" :key="tech.id">
<option :value="tech.id" x-text="tech.role === 'Sub-contractor' ? `${tech.full_name} (sub-contractor)` : tech.full_name"></option>
</template>
</select>
<p class="mt-1 text-xs text-gray-400">Technicians and sub-contractors. Leaving blank dispatches via the FM queue.</p>
</div>
</div>
<!-- Reported date (backdating support) --> <!-- Reported date (backdating support) -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
@@ -215,7 +229,8 @@
description: '', description: '',
reporter: '', reporter: '',
reported_via: '', reported_via: '',
reported_date: '' reported_date: '',
assigned_to: ''
}, },
todayStr: '', todayStr: '',
priorityAuto: false, priorityAuto: false,
@@ -230,10 +245,12 @@
photoPreviews: [], photoPreviews: [],
submitting: false, submitting: false,
error: '', error: '',
technicians: [],
async init() { async init() {
await this.loadCategories(); await this.loadCategories();
await this.loadUnits(); await this.loadUnits();
await this.loadTechnicians();
// Default reported date to today (local), allow backdating via the date picker // Default reported date to today (local), allow backdating via the date picker
this.todayStr = this.localDateStr(new Date()); this.todayStr = this.localDateStr(new Date());
if (!this.form.reported_date) this.form.reported_date = this.todayStr; if (!this.form.reported_date) this.form.reported_date = this.todayStr;
@@ -279,6 +296,15 @@
} catch (e) { console.error('Units load error', e); } } catch (e) { console.error('Units load error', e); }
}, },
async loadTechnicians() {
try {
// "Assign to" pool: the 5 client-confirmed technicians
// plus sub-contractors (client directive 2026-09-28).
const data = await app().apiGet('/api/auth/assignees');
this.technicians = data || [];
} catch (e) { console.error('Assignees load error', e); }
},
// ── Location cascade ───────────────────────────────────── // ── Location cascade ─────────────────────────────────────
onPropertyChange() { onPropertyChange() {
this.form.building = ''; this.form.building = '';
@@ -404,6 +430,7 @@
customer_name: this.form.customer_name || null, customer_name: this.form.customer_name || null,
phone: this.form.phone || null, phone: this.form.phone || null,
reported_at: this.form.reported_date || null, reported_at: this.form.reported_date || null,
assigned_to: this.form.assigned_to ? parseInt(this.form.assigned_to) : null,
}; };
const ticket = await app().apiPost('/api/tickets', payload); const ticket = await app().apiPost('/api/tickets', payload);
+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
+8 -1
View File
@@ -192,12 +192,19 @@ async def test_units_grouped_shape(client):
assert "East" in grouped and "West" in grouped assert "East" in grouped and "West" in grouped
east = grouped["East"] east = grouped["East"]
assert "Pavilion East" in east assert "Pavilion East" in east
assert len(east["Pavilion East"]) == 60 # 10 floors x 6 apartments + 2 penthouse units (PH1E, PH2E) —
# client directive 2026-09-28: penthouses must be selectable.
assert len(east["Pavilion East"]) == 62
unit = east["Pavilion East"][0] unit = east["Pavilion East"][0]
assert {"id", "property", "apartment_code", "building", "floor"} <= set(unit.keys()) assert {"id", "property", "apartment_code", "building", "floor"} <= set(unit.keys())
# Distinct apartment codes # Distinct apartment codes
codes = [u["apartment_code"] for u in east["Pavilion East"]] codes = [u["apartment_code"] for u in east["Pavilion East"]]
assert len(set(codes)) == len(codes) assert len(set(codes)) == len(codes)
# Penthouse units (East/West) are present and selectable
assert {"PH1E", "PH2E"} <= set(codes)
west = grouped["West"]
west_codes = [u["apartment_code"] for u in west["Pavilion West"]]
assert {"PH1W", "PH2W"} <= set(west_codes)
async def test_units_grouped_property_filter(client): async def test_units_grouped_property_filter(client):
+288
View File
@@ -0,0 +1,288 @@
"""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 _make_user("prosper@denya.com", "Prosper")
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 _make_user("prosper@denya.com", "Prosper")
sam = await _make_user("sam@denya.com", "Sam")
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 _make_user("afful@denya.com", "Afful")
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
+197
View File
@@ -0,0 +1,197 @@
"""Regression tests for Wahab Abdul's 2026-09-28 directives.
Covers the three client decisions:
1. Technician roster converges to exactly 5 named techs (others deactivated,
never deleted — ticket history must survive).
2. Sub-contractors appear in the "Assign to" pool alongside technicians.
3. Penthouse units (East/West) are selectable when raising a ticket.
"""
import pytest
import pytest_asyncio
from app.core.database import async_session_factory
from app.core.roles import (
ASSIGNEE_POOL_ROLES,
SUBCONTRACTOR_ROLE,
TECHNICIAN_ROLE,
)
from app.models.ticket import Ticket
from app.models.user import User
pytestmark = pytest.mark.asyncio
@pytest_asyncio.fixture
async def db():
"""Direct DB session bound to the same test database as `client`.
conftest.py only exposes `client`, so DB-level assertions open their own
session against the same engine.
"""
async with async_session_factory() as session:
yield session
CONFIRMED_TECHS = (
"Samuel Shang",
"Desmond Afful",
"Desmond Odekyi",
"Francis Norgbey",
"Nicholas Nartey",
)
async def _make_user(db, email, full_name, role, active=True):
"""Insert a user row directly (no HTTP admin round-trip)."""
user = User(
email=email,
full_name=full_name,
role=role,
password_hash="not-a-real-hash",
active=active,
)
db.add(user)
await db.flush()
return user
def _grouped_codes(grouped):
"""Flatten {property: {building: [units]}} to a set of apartment codes."""
return {
u["apartment_code"]
for wing in grouped.values()
for units in wing.values()
for u in units
}
# ── 1. Technician roster ───────────────────────────────────────────────
async def test_confirmed_techs_are_active_and_assignable(client, db):
"""All 5 client-confirmed technicians exist, active, role=Tech."""
for name in CONFIRMED_TECHS:
user = await _make_user(
db, f"{name.lower().replace(' ', '.')}@denya.test", name, TECHNICIAN_ROLE
)
assert user.active is True
assert user.role in ASSIGNEE_POOL_ROLES
async def test_roster_converge_is_idempotent(client, db):
"""Running convergence twice leaves exactly the 5 confirmed techs."""
from sqlalchemy import select
from app.services.roster import converge_tech_roster
await converge_tech_roster(db)
await db.flush()
await converge_tech_roster(db)
await db.flush()
result = await db.execute(
select(User).where(User.role == TECHNICIAN_ROLE, User.active.is_(True))
)
active_techs = result.scalars().all()
assert {t.full_name for t in active_techs} == set(CONFIRMED_TECHS)
async def test_roster_converge_deactivates_not_deletes(client, db):
"""Off-roster techs are deactivated, never hard-deleted.
Deleting would orphan ticket history. The account row must survive so
previously-raised tickets keep a valid assignee reference.
"""
from sqlalchemy import select
from app.services.roster import converge_tech_roster
stale = await _make_user(db, "stale.tech@denya.test", "Stale Tech", TECHNICIAN_ROLE)
stale_id = stale.id
await db.flush()
await converge_tech_roster(db)
await db.flush()
row = (
await db.execute(select(User).where(User.id == stale_id))
).scalar_one_or_none()
assert row is not None, "off-roster tech must NOT be deleted"
assert row.active is False, "off-roster tech must be deactivated"
# ── 2. Sub-contractors in the assign-to pool ───────────────────────────
async def test_subcontractor_role_is_in_assignee_pool():
"""Sub-contractor is a canonical role inside the assignee pool."""
assert SUBCONTRACTOR_ROLE in ASSIGNEE_POOL_ROLES
assert TECHNICIAN_ROLE in ASSIGNEE_POOL_ROLES
async def test_subcontractor_can_own_a_ticket(client, db):
"""A ticket can reference an active sub-contractor as assignee.
Sub-contractors are external, tracked "under tech" by the FM
coordinator, but they must be assignable.
The ticket is created in this session rather than via the `seed_tickets`
fixture: that fixture opens its own session against the same file-backed
SQLite DB and the two concurrent writers deadlock it.
"""
from sqlalchemy import select
sub = await _make_user(
db, "sub.assign@denya.test", "External Sub", SUBCONTRACTOR_ROLE
)
ticket = Ticket(
ticket_number="PAV-SUB-00001",
status="Logged",
priority="medium",
description="Sub-contractor assignment check",
)
db.add(ticket)
await db.flush()
ticket.assigned_to = sub.id
await db.flush()
ticket_id = ticket.id
reloaded = (
await db.execute(select(Ticket).where(Ticket.id == ticket_id))
).scalar_one()
assert reloaded.assigned_to == sub.id
# ── 3. Penthouse units selectable ──────────────────────────────────────
async def test_penthouse_units_present_and_selectable(client):
"""All 4 penthouse units are selectable when raising a ticket."""
resp = await client.get("/api/tickets/units/grouped")
assert resp.status_code == 200
codes = _grouped_codes(resp.json())
for code in ("PH1E", "PH1W", "PH2E", "PH2W"):
assert code in codes, f"{code} must be selectable"
async def test_penthouse_codes_have_no_dangling_dash(client):
"""Legacy malformed codes (PH1E-) are renamed to clean form."""
resp = await client.get("/api/tickets/units/grouped")
codes = _grouped_codes(resp.json())
assert not any(c.endswith("-") for c in codes), "no dangling-dash codes"
async def test_penthouse_units_have_floors(client):
"""Penthouse units carry a floor so the 3-level picker can group them."""
resp = await client.get("/api/tickets/units/grouped")
penthouses = [
u
for wing in resp.json().values()
for units in wing.values()
for u in units
if u["apartment_code"].startswith("PH")
]
assert len(penthouses) == 4
assert all(u["floor"] is not None for u in penthouses)
+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