Files
denya-onecare/app/core/roles.py
T
Mumuni (Hermes) 106699ac5e feat: apply Wahab Abdul's 2026-09-28 directives (roster, sub-contractors, penthouses)
Three client decisions for Denya OneCare / Pavilion Accra:

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

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

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

Tests: new tests/test_wahab_directives_20260928.py (8 tests); updated the
stale East unit count in test_categories_and_units.py (60 -> 62 with penthouses).
Full suite green.
2026-09-28 21:42:56 +00:00

121 lines
4.8 KiB
Python

"""Unified role model — single source of truth for user roles.
HARDENING (P0 batch): every role string used by seeds, RBAC checks, admin
user management, login, and JWT validation derives from this module so the
system can never silently drift between role vocabularies.
Canonical roles are the human-readable taxonomy the whole product already
uses (PRD §4, ``app/services/seed.py``, the frontend nav in base.html):
Admin/Jerome, Admin/Wahab, CS Rep, CS Manager, FM Dispatcher,
Tech, CEO, Director
Legacy databases created under the pre-P0 open-registration builds can carry
lowercase/nickname role strings (``technician``, ``cs``, ``fm``, ``ceo``,
``admin``, ``superadmin`` …) and underscore variants of the space-separated
ones (``cs_rep``, ``cs_manager``, ``fm_dispatcher``). ``ROLE_ALIASES`` maps
the *unambiguous* nicknames onto a canonical role so startup normalization (see
``app/services/seed.py::normalize_legacy_user_roles``) can converge the data.
``admin`` / ``superadmin`` are deliberately NOT aliased: they are
identity-ambiguous (they cannot be attributed to Jerome or Wahab) and were
mintable by anyone during the open-registration window, so they are treated
as unknown and fail closed — the operator must remediate those rows manually
(role cleanup on the live DB is owned by the deployer).
"""
from __future__ import annotations
# ── Canonical taxonomy ────────────────────────────────────────────────
# Order is cosmetic; membership is what matters.
CANONICAL_ROLES: tuple[str, ...] = (
"Admin/Jerome",
"Admin/Wahab",
"CS Rep",
"CS Manager",
"FM Dispatcher",
"Tech",
"Sub-contractor",
"CEO",
"Director",
)
# Roles that pass admin gates (ticket DELETE, user management, …).
ADMIN_ROLES: tuple[str, ...] = ("Admin/Jerome", "Admin/Wahab")
# Roles shown to the frontend nav/assignment helpers as "technician" pool.
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) ───────────────
# Keys are lowercased. Unambiguous nicknames from legacy/early seeds and the
# brief's role model ("technician/cs/fm/ceo") converge onto canonical roles.
ROLE_ALIASES: dict[str, str] = {
"technician": TECHNICIAN_ROLE,
"tech": TECHNICIAN_ROLE,
"cs": "CS Rep",
"cs rep": "CS Rep",
"cs_rep": "CS Rep",
"cs representative": "CS Rep",
"cs manager": "CS Manager",
"cs_manager": "CS Manager",
"fm": "FM Dispatcher",
"fm dispatcher": "FM Dispatcher",
"fm_dispatcher": "FM Dispatcher",
"sub-contractor": SUBCONTRACTOR_ROLE,
"sub contractor": SUBCONTRACTOR_ROLE,
"subcontractor": SUBCONTRACTOR_ROLE,
"sub_contractor": SUBCONTRACTOR_ROLE,
"ceo": "CEO",
"director": "Director",
}
# Aliases that are explicitly NOT auto-mapped (identity-ambiguous and/or
# mintable by the old open register). They stay unknown → denied at login
# and JWT validation until an operator remediates the row.
_BLOCKED_LEGACY_ROLES = frozenset({"admin", "superadmin", "administrator"})
def normalize_role(role: str | None) -> str | None:
"""Return the canonical role for *role*, or ``None`` when unrecognised.
``Admin/Jerome`` → ``Admin/Jerome``; ``technician`` → ``Tech``;
``superadmin`` → ``None`` (unknown; caller must fail closed).
"""
if not role:
return None
stripped = role.strip()
if stripped in CANONICAL_ROLES:
return stripped
return ROLE_ALIASES.get(stripped.lower())
def is_known_role(role: str | None) -> bool:
"""True when *role* is canonical or maps to a canonical role."""
return normalize_role(role) is not None
def is_admin_role(role: str | None) -> bool:
"""True when *role* is one of the canonical administrator roles."""
return normalize_role(role) in ADMIN_ROLES
def is_blocked_legacy_role(role: str | None) -> bool:
"""True for legacy ``admin``/``superadmin`` rows that need remediation.
Such rows are not canonical, are not auto-mapped, and must not pass any
authorization gate; an operator should reassign or remove them.
"""
return bool(role) and role.strip().lower() in _BLOCKED_LEGACY_ROLES