feat: Wahab 2026-09-28 directives - 5-tech roster, sub-contractors in assign list, penthouse units #17

Open
mumuni-bot wants to merge 1 commits from fm/denya-wahab-answers-20260928 into main
13 changed files with 481 additions and 21 deletions
+4 -4
View File
@@ -807,25 +807,25 @@
"floor": 7
},
{
"apartment_code": "PH1E-",
"apartment_code": "PH1E",
"property": "East",
"building": "Pavilion East",
"floor": null
},
{
"apartment_code": "PH1W-",
"apartment_code": "PH1W",
"property": "West",
"building": "Pavilion West",
"floor": null
},
{
"apartment_code": "PH2E-",
"apartment_code": "PH2E",
"property": "East",
"building": "Pavilion East",
"floor": null
},
{
"apartment_code": "PH2W-",
"apartment_code": "PH2W",
"property": "West",
"building": "Pavilion West",
"floor": null
+17
View File
@@ -35,6 +35,7 @@ CANONICAL_ROLES: tuple[str, ...] = (
"CS Manager",
"FM Dispatcher",
"Tech",
"Sub-contractor",
"CEO",
"Director",
)
@@ -45,6 +46,18 @@ 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.
@@ -60,6 +73,10 @@ ROLE_ALIASES: dict[str, str] = {
"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",
}
+4
View File
@@ -14,6 +14,7 @@ from sqlalchemy import text
from app.core.config import settings
from app.core.database import Base, async_session_factory, engine
from app.routers import auth, health, pages, tickets, whatsapp
from app.services.roster import converge_tech_roster
from app.services.seed import (
normalize_legacy_user_emails,
normalize_legacy_user_roles,
@@ -118,6 +119,9 @@ async def lifespan(app: FastAPI):
# ``technician``/``cs``/``fm``) onto the canonical taxonomy at startup.
await normalize_legacy_user_roles(session)
await normalize_legacy_user_emails(session)
# Client directive 2026-09-28: the active Tech pool is exactly the
# five confirmed names; any other active Tech is deactivated here.
await converge_tech_roster(session)
await session.commit()
await seed_units(session, json_path=str(settings.BASE_DIR / "apartment_mapping.json"))
await session.commit()
+20 -1
View File
@@ -14,7 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.ratelimit import login_rate_limiter
from app.core.roles import ADMIN_ROLES
from app.core.roles import ADMIN_ROLES, ASSIGNEE_POOL_ROLES
from app.core.security import get_current_user, require_roles
from app.models.user import User
from app.schemas.auth import (
@@ -88,6 +88,25 @@ async def list_users(
return list(result.scalars().all())
@router.get("/assignees", response_model=list[UserOut])
async def list_assignees(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> list[User]:
"""Active users selectable in the "Assign to" pickers.
The pool is the technician roster plus sub-contractors (client
directive 2026-09-28): external labour assigned directly, tracked
under the FM coordinator. Deactivated accounts never appear.
"""
result = await db.execute(
select(User)
.where(User.role.in_(ASSIGNEE_POOL_ROLES), User.active.is_(True))
.order_by(User.full_name)
)
return list(result.scalars().all())
# ── Admin user management ────────────────────────────────────────────
@router.post("/users", response_model=UserOut, status_code=status.HTTP_201_CREATED)
async def create_user(
+82
View File
@@ -0,0 +1,82 @@
"""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
+70 -7
View File
@@ -20,7 +20,25 @@ from app.models.category import Category
logger = logging.getLogger(__name__)
# Penthouse (Pavilion pent-house) units. Client directive 2026-09-28
# (Wahab Abdul): "PHE1 & PHW2 means Pent House East/West, they must be
# selectable when raising a ticket. they should be added to the units".
# The mapping file shipped with dangling-dash codes (PH1E-…) and null floors;
# they are cleaned here and self-healed in any existing database.
_PENTHOUSE_FLOORS = {"PH1E": 1, "PH1W": 1, "PH2E": 2, "PH2W": 2}
_LEGACY_PENTHOUSE_RENAMES = {
"PH1E-": "PH1E", "PH1W-": "PH1W", "PH2E-": "PH2E", "PH2W-": "PH2W",
}
# ── Seed user data (dicts to avoid module-level model instantiation) ─
# TECH POOL (client directive 2026-09-28, Wahab Abdul — "these 5 names and
# nothing else"): Samuel Shang, Desmond Afful, Desmond Odekyi, Francis
# Norgbey, Nicholas Nartey. Emails are placeholders until the client confirms
# them; the picker shows full names, and login matching is by email only.
# The old nickname Techs (Prosper/Sam/Steven/Junior/Francis/Desmond/Afful)
# are removed from the seed; ``roster.converge_tech_roster`` deactivates any
# such account still present in a live database (never deletes — ticket
# history holds ``users.id`` FKs).
SEED_USERS_DATA = [
{"email": "jerome@denya.com", "full_name": "Jerome Tabiri", "phone": "+233000000001", "role": "Admin/Jerome"},
{"email": "wahab@denya.com", "full_name": "Wahab", "phone": "+233000000002", "role": "Admin/Wahab"},
@@ -30,17 +48,24 @@ SEED_USERS_DATA = [
{"email": "ama@denya.com", "full_name": "Ama", "phone": "+233000000006", "role": "CS Rep"},
{"email": "nicholas@denya.com", "full_name": "Nicholas", "phone": "+233000000007", "role": "FM Dispatcher"},
{"email": "collins@denya.com", "full_name": "Collins", "phone": "+233000000008", "role": "FM Dispatcher"},
{"email": "prosper@denya.com", "full_name": "Prosper", "phone": "+233000000010", "role": "Tech"},
{"email": "sam@denya.com", "full_name": "Sam", "phone": "+233000000011", "role": "Tech"},
{"email": "steven@denya.com", "full_name": "Steven", "phone": "+233000000012", "role": "Tech"},
{"email": "junior@denya.com", "full_name": "Junior (Samuel)", "phone": "+233000000013", "role": "Tech"},
{"email": "francis@denya.com", "full_name": "Francis", "phone": "+233000000014", "role": "Tech"},
{"email": "desmond@denya.com", "full_name": "Desmond Afful", "phone": "+233000000015", "role": "Tech"},
{"email": "afful@denya.com", "full_name": "Afful", "phone": "+233000000016", "role": "Tech"},
{"email": "samuel.shang@denya.com", "full_name": "Samuel Shang", "phone": "+233000000010", "role": "Tech"},
{"email": "desmond.afful@denya.com", "full_name": "Desmond Afful", "phone": "+233000000015", "role": "Tech"},
{"email": "desmond.odekyi@denya.com", "full_name": "Desmond Odekyi", "phone": "+233000000011", "role": "Tech"},
{"email": "francis.norgbey@denya.com", "full_name": "Francis Norgbey", "phone": "+233000000014", "role": "Tech"},
{"email": "nicholas.nartey@denya.com", "full_name": "Nicholas Nartey", "phone": "+233000000013", "role": "Tech"},
{"email": "scott@denya.com", "full_name": "Scott Murray", "phone": "+233000000020", "role": "CEO"},
{"email": "director@denya.com", "full_name": "Director", "phone": "+233000000021", "role": "Director"},
]
# The confirmed Tech pool, single source of truth for roster convergence.
CONFIRMED_TECH_EMAILS = [
"samuel.shang@denya.com",
"desmond.afful@denya.com",
"desmond.odekyi@denya.com",
"francis.norgbey@denya.com",
"nicholas.nartey@denya.com",
]
async def normalize_legacy_user_roles(db: AsyncSession) -> int:
"""Converge legacy role strings onto the unified canonical taxonomy.
@@ -178,6 +203,15 @@ async def seed_units(db: AsyncSession, json_path: str | Path | None = None) -> l
"building": f"Pavilion {wing}",
"floor": floor,
})
# Penthouse units (client directive 2026-09-28: must be
# selectable when raising a ticket)
for level in (1, 2):
units_data.append({
"apartment_code": f"PH{level}{wing[0]}",
"property": wing,
"building": f"Pavilion {wing}",
"floor": _PENTHOUSE_FLOORS[f"PH{level}{wing[0]}"],
})
created: list[Unit] = []
for entry in units_data:
@@ -194,6 +228,35 @@ async def seed_units(db: AsyncSession, json_path: str | Path | None = None) -> l
)
db.add(unit)
created.append(unit)
# Self-heal malformed penthouse codes from earlier mappings
# (``PH1E-`` with a dangling dash). Client directive 2026-09-28: the
# penthouse units (Pent House East/West) must be selectable when raising
# a ticket — they were present but mangled. Rename in place so any
# ``tickets.unit_id`` FKs stay intact; only rename when the clean code
# is free, and deactivate (never delete) a row that would collide.
renamed = 0
for legacy_code, clean_code in _LEGACY_PENTHOUSE_RENAMES.items():
legacy_row = (
await db.execute(select(Unit).where(Unit.apartment_code == legacy_code))
).scalar_one_or_none()
if legacy_row is None or legacy_row.apartment_code == clean_code:
continue
clash = (
await db.execute(select(Unit).where(Unit.apartment_code == clean_code))
).scalar_one_or_none()
if clash is not None:
# A clean-code row already exists; leave the legacy row as an
# inert historical alias (its tickets keep pointing at it).
continue
legacy_row.apartment_code = clean_code
if legacy_row.floor is None:
legacy_row.floor = _PENTHOUSE_FLOORS.get(clean_code)
renamed += 1
if renamed:
await db.flush()
logger.info("Repaired %d legacy penthouse unit codes", renamed)
if created:
await db.flush()
for u in created:
+37
View File
@@ -10,6 +10,7 @@ from sqlalchemy import delete as sa_delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.roles import ASSIGNEE_POOL_ROLES
from app.models.ticket import Escalation, Ticket, TicketPhoto, TicketTimeline
from app.models.unit import Unit
from app.models.user import User
@@ -105,6 +106,26 @@ async def _get_ticket_or_404(db: AsyncSession, ticket_id: int) -> Ticket:
# ── CRUD ─────────────────────────────────────────────────────────────
async def _validate_assignee(db: AsyncSession, assigned_to: int | None) -> None:
"""Assignment must land on an active member of the pool (Tech roster +
sub-contractors, client directive 2026-09-28). Deactivated accounts —
replaced technicians included — can no longer receive new work."""
if assigned_to is None:
return
user = (
await db.execute(select(User).where(User.id == assigned_to))
).scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail="Assignee not found")
if not user.active or user.role not in ASSIGNEE_POOL_ROLES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"{user.full_name} is not in the assignable pool "
f"({'/'.join(ASSIGNEE_POOL_ROLES)}, active only)",
)
async def create_ticket(
db: AsyncSession,
data: dict[str, Any],
@@ -118,6 +139,7 @@ async def create_ticket(
# when omitted the ticket is considered reported right now. The SLA clock
# is unchanged — deadlines run from creation time, not the reported date.
reported_at = data.get("reported_at") or datetime.now(timezone.utc)
await _validate_assignee(db, data.get("assigned_to"))
ticket = Ticket(
ticket_number=ticket_number,
@@ -158,6 +180,20 @@ async def create_ticket(
user_id=user.id if user else None,
)
# Assigning at creation advances Logged → Assigned (same rule as PATCH
# /tickets/{id} — client directive 2026-09-28 added the "Assign to"
# picker on the new-ticket form).
if ticket.assigned_to:
ticket.status = "Assigned"
await _log_status_change(
db,
ticket.id,
from_status="Logged",
to_status="Assigned",
note="Assigned at creation",
user_id=user.id if user else None,
)
await db.flush()
await db.refresh(ticket)
return ticket
@@ -237,6 +273,7 @@ async def update_ticket(
) -> Ticket:
"""Update a ticket. Status changes are validated and logged."""
ticket = await _get_ticket_or_404(db, ticket_id)
await _validate_assignee(db, data.get("assigned_to"))
# Handle status transitions separately
new_status = data.get("status")
+4
View File
@@ -183,6 +183,10 @@
// Role helpers
get isCS() { return ['CS Rep', 'CS Manager'].includes(this.user.role) },
get isFM() { return ['FM Dispatcher', 'Admin/Jerome', 'Admin/Wahab'].includes(this.user.role) },
// Sub-contractors are external labour tracked under the FM
// coordinator — client directive 2026-09-28 puts them in the
// Tech pool ("under tech"), so they share the technician UX.
get isTech() { return ['Tech', 'Sub-contractor'].includes(this.user.role) },
get isExecutive() { return ['CEO', 'Director'].includes(this.user.role) },
get isAdmin() { return ['Admin/Jerome', 'Admin/Wahab'].includes(this.user.role) },
+5 -3
View File
@@ -342,9 +342,11 @@
async loadTechnicians() {
try {
const users = await app().apiGet('/api/auth/users');
// Assign dropdown should only offer Tech-role staff
this.technicians = (users || []).filter(u => u.role === 'Tech');
// Assignable pool: active Techs + sub-contractors
// (client directive 2026-09-28). Deactivated, replaced
// technicians are excluded server-side.
const users = await app().apiGet('/api/auth/assignees');
this.technicians = users || [];
} catch (e) {
console.error('Technicians load error', e);
this.technicians = [];
+28 -1
View File
@@ -154,6 +154,20 @@
</div>
</div>
<!-- Assign to (optional at creation; Tech roster + sub-contractors) -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Assign To</label>
<select x-model="form.assigned_to" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none">
<option value="">Unassigned (dispatch later)</option>
<template x-for="tech in technicians" :key="tech.id">
<option :value="tech.id" x-text="tech.role === 'Sub-contractor' ? `${tech.full_name} (sub-contractor)` : tech.full_name"></option>
</template>
</select>
<p class="mt-1 text-xs text-gray-400">Technicians and sub-contractors. Leaving blank dispatches via the FM queue.</p>
</div>
</div>
<!-- Reported date (backdating support) -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
@@ -215,7 +229,8 @@
description: '',
reporter: '',
reported_via: '',
reported_date: ''
reported_date: '',
assigned_to: ''
},
todayStr: '',
priorityAuto: false,
@@ -230,10 +245,12 @@
photoPreviews: [],
submitting: false,
error: '',
technicians: [],
async init() {
await this.loadCategories();
await this.loadUnits();
await this.loadTechnicians();
// Default reported date to today (local), allow backdating via the date picker
this.todayStr = this.localDateStr(new Date());
if (!this.form.reported_date) this.form.reported_date = this.todayStr;
@@ -279,6 +296,15 @@
} catch (e) { console.error('Units load error', e); }
},
async loadTechnicians() {
try {
// "Assign to" pool: the 5 client-confirmed technicians
// plus sub-contractors (client directive 2026-09-28).
const data = await app().apiGet('/api/auth/assignees');
this.technicians = data || [];
} catch (e) { console.error('Assignees load error', e); }
},
// ── Location cascade ─────────────────────────────────────
onPropertyChange() {
this.form.building = '';
@@ -404,6 +430,7 @@
customer_name: this.form.customer_name || null,
phone: this.form.phone || null,
reported_at: this.form.reported_date || null,
assigned_to: this.form.assigned_to ? parseInt(this.form.assigned_to) : null,
};
const ticket = await app().apiPost('/api/tickets', payload);
+8 -1
View File
@@ -192,12 +192,19 @@ async def test_units_grouped_shape(client):
assert "East" in grouped and "West" in grouped
east = grouped["East"]
assert "Pavilion East" in east
assert len(east["Pavilion East"]) == 60
# 10 floors x 6 apartments + 2 penthouse units (PH1E, PH2E) —
# client directive 2026-09-28: penthouses must be selectable.
assert len(east["Pavilion East"]) == 62
unit = east["Pavilion East"][0]
assert {"id", "property", "apartment_code", "building", "floor"} <= set(unit.keys())
# Distinct apartment codes
codes = [u["apartment_code"] for u in east["Pavilion East"]]
assert len(set(codes)) == len(codes)
# Penthouse units (East/West) are present and selectable
assert {"PH1E", "PH2E"} <= set(codes)
west = grouped["West"]
west_codes = [u["apartment_code"] for u in west["Pavilion West"]]
assert {"PH1W", "PH2W"} <= set(west_codes)
async def test_units_grouped_property_filter(client):
+5 -4
View File
@@ -44,6 +44,7 @@ async def _user_id(email: str) -> int:
return user.id
async def _seed_tickets(specs: list[dict]) -> None:
"""Insert tickets directly so tests control status/timestamps exactly."""
async with async_session_factory() as session:
@@ -84,7 +85,7 @@ async def test_workload_sources_report_real_technician_names(client):
ticket both must expose the technician's real full name, never a
``Tech #<id>`` placeholder.
"""
prosper = await _user_id("prosper@denya.com")
prosper = await _make_user("prosper@denya.com", "Prosper")
await _seed_tickets([_ticket("In Progress", prosper), _ticket("Escalated", prosper)])
token = await _login(client)
@@ -156,8 +157,8 @@ async def test_tech_performance_empty_state(client):
async def test_tech_performance_aggregates_by_real_name(client):
"""Counts, completion rate, aging, sorting and totals per technician."""
prosper = await _user_id("prosper@denya.com")
sam = await _user_id("sam@denya.com")
prosper = await _make_user("prosper@denya.com", "Prosper")
sam = await _make_user("sam@denya.com", "Sam")
await _seed_tickets(
[
@@ -249,7 +250,7 @@ async def test_tech_performance_aggregates_by_real_name(client):
async def test_tech_performance_reports_counts_when_timestamps_absent(client):
"""Finished tasks without ``closed_at`` yield no average but a count."""
afful = await _user_id("afful@denya.com")
afful = await _make_user("afful@denya.com", "Afful")
await _seed_tickets([
_ticket("Completed", afful),
_ticket("Closed", afful),
+197
View File
@@ -0,0 +1,197 @@
"""Regression tests for Wahab Abdul's 2026-09-28 directives.
Covers the three client decisions:
1. Technician roster converges to exactly 5 named techs (others deactivated,
never deleted — ticket history must survive).
2. Sub-contractors appear in the "Assign to" pool alongside technicians.
3. Penthouse units (East/West) are selectable when raising a ticket.
"""
import pytest
import pytest_asyncio
from app.core.database import async_session_factory
from app.core.roles import (
ASSIGNEE_POOL_ROLES,
SUBCONTRACTOR_ROLE,
TECHNICIAN_ROLE,
)
from app.models.ticket import Ticket
from app.models.user import User
pytestmark = pytest.mark.asyncio
@pytest_asyncio.fixture
async def db():
"""Direct DB session bound to the same test database as `client`.
conftest.py only exposes `client`, so DB-level assertions open their own
session against the same engine.
"""
async with async_session_factory() as session:
yield session
CONFIRMED_TECHS = (
"Samuel Shang",
"Desmond Afful",
"Desmond Odekyi",
"Francis Norgbey",
"Nicholas Nartey",
)
async def _make_user(db, email, full_name, role, active=True):
"""Insert a user row directly (no HTTP admin round-trip)."""
user = User(
email=email,
full_name=full_name,
role=role,
password_hash="not-a-real-hash",
active=active,
)
db.add(user)
await db.flush()
return user
def _grouped_codes(grouped):
"""Flatten {property: {building: [units]}} to a set of apartment codes."""
return {
u["apartment_code"]
for wing in grouped.values()
for units in wing.values()
for u in units
}
# ── 1. Technician roster ───────────────────────────────────────────────
async def test_confirmed_techs_are_active_and_assignable(client, db):
"""All 5 client-confirmed technicians exist, active, role=Tech."""
for name in CONFIRMED_TECHS:
user = await _make_user(
db, f"{name.lower().replace(' ', '.')}@denya.test", name, TECHNICIAN_ROLE
)
assert user.active is True
assert user.role in ASSIGNEE_POOL_ROLES
async def test_roster_converge_is_idempotent(client, db):
"""Running convergence twice leaves exactly the 5 confirmed techs."""
from sqlalchemy import select
from app.services.roster import converge_tech_roster
await converge_tech_roster(db)
await db.flush()
await converge_tech_roster(db)
await db.flush()
result = await db.execute(
select(User).where(User.role == TECHNICIAN_ROLE, User.active.is_(True))
)
active_techs = result.scalars().all()
assert {t.full_name for t in active_techs} == set(CONFIRMED_TECHS)
async def test_roster_converge_deactivates_not_deletes(client, db):
"""Off-roster techs are deactivated, never hard-deleted.
Deleting would orphan ticket history. The account row must survive so
previously-raised tickets keep a valid assignee reference.
"""
from sqlalchemy import select
from app.services.roster import converge_tech_roster
stale = await _make_user(db, "stale.tech@denya.test", "Stale Tech", TECHNICIAN_ROLE)
stale_id = stale.id
await db.flush()
await converge_tech_roster(db)
await db.flush()
row = (
await db.execute(select(User).where(User.id == stale_id))
).scalar_one_or_none()
assert row is not None, "off-roster tech must NOT be deleted"
assert row.active is False, "off-roster tech must be deactivated"
# ── 2. Sub-contractors in the assign-to pool ───────────────────────────
async def test_subcontractor_role_is_in_assignee_pool():
"""Sub-contractor is a canonical role inside the assignee pool."""
assert SUBCONTRACTOR_ROLE in ASSIGNEE_POOL_ROLES
assert TECHNICIAN_ROLE in ASSIGNEE_POOL_ROLES
async def test_subcontractor_can_own_a_ticket(client, db):
"""A ticket can reference an active sub-contractor as assignee.
Sub-contractors are external, tracked "under tech" by the FM
coordinator, but they must be assignable.
The ticket is created in this session rather than via the `seed_tickets`
fixture: that fixture opens its own session against the same file-backed
SQLite DB and the two concurrent writers deadlock it.
"""
from sqlalchemy import select
sub = await _make_user(
db, "sub.assign@denya.test", "External Sub", SUBCONTRACTOR_ROLE
)
ticket = Ticket(
ticket_number="PAV-SUB-00001",
status="Logged",
priority="medium",
description="Sub-contractor assignment check",
)
db.add(ticket)
await db.flush()
ticket.assigned_to = sub.id
await db.flush()
ticket_id = ticket.id
reloaded = (
await db.execute(select(Ticket).where(Ticket.id == ticket_id))
).scalar_one()
assert reloaded.assigned_to == sub.id
# ── 3. Penthouse units selectable ──────────────────────────────────────
async def test_penthouse_units_present_and_selectable(client):
"""All 4 penthouse units are selectable when raising a ticket."""
resp = await client.get("/api/tickets/units/grouped")
assert resp.status_code == 200
codes = _grouped_codes(resp.json())
for code in ("PH1E", "PH1W", "PH2E", "PH2W"):
assert code in codes, f"{code} must be selectable"
async def test_penthouse_codes_have_no_dangling_dash(client):
"""Legacy malformed codes (PH1E-) are renamed to clean form."""
resp = await client.get("/api/tickets/units/grouped")
codes = _grouped_codes(resp.json())
assert not any(c.endswith("-") for c in codes), "no dangling-dash codes"
async def test_penthouse_units_have_floors(client):
"""Penthouse units carry a floor so the 3-level picker can group them."""
resp = await client.get("/api/tickets/units/grouped")
penthouses = [
u
for wing in resp.json().values()
for units in wing.values()
for u in units
if u["apartment_code"].startswith("PH")
]
assert len(penthouses) == 4
assert all(u["floor"] is not None for u in penthouses)