Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
106699ac5e | ||
|
|
8ba04f9851 | ||
|
|
b7f36ac3b1 | ||
|
|
43800345c1 | ||
|
|
57cbe34117 | ||
|
|
a6eb799efa |
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|--------|------|------|-------------|
|
|--------|------|------|-------------|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 793 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 118 KiB |
@@ -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) },
|
||||||
|
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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 & 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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||