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