Files
denya-onecare/app/services/roster.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

83 lines
3.3 KiB
Python

"""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