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.
121 lines
4.8 KiB
Python
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
|