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.
83 lines
3.3 KiB
Python
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
|