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.
388 lines
18 KiB
Python
388 lines
18 KiB
Python
"""Seed script — users, units, and categories.
|
|
|
|
Called on first startup via lifespan hook.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.security import hash_password
|
|
from app.core.roles import is_blocked_legacy_role, normalize_role
|
|
from app.models.unit import Unit
|
|
from app.models.user import User
|
|
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"},
|
|
{"email": "bella@denya.com", "full_name": "Bella", "phone": "+233000000003", "role": "CS Rep"},
|
|
{"email": "akua@denya.com", "full_name": "Akua", "phone": "+233000000004", "role": "CS Manager"},
|
|
{"email": "mercy@denya.com", "full_name": "Mercy Duah", "phone": "+233000000005", "role": "CS Manager"},
|
|
{"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": "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.
|
|
|
|
Databases built before the P0 role-model batch can hold nickname roles
|
|
(``technician``, ``cs``, ``fm``, ``ceo`` …) minted by the old open
|
|
self-registration. Unambiguous aliases are rewritten to their canonical
|
|
role so RBAC keeps working. Ambiguous/unknown roles (e.g. lowercase
|
|
``admin``/``superadmin``) are NOT auto-mapped — they fail closed at login
|
|
and JWT validation until an operator remediates the row.
|
|
|
|
Returns the number of rows rewritten. Idempotent.
|
|
"""
|
|
result = await db.execute(select(User))
|
|
changed = 0
|
|
for user in result.scalars().all():
|
|
canonical = normalize_role(user.role)
|
|
if canonical and canonical != user.role:
|
|
logger.info("Normalizing legacy role %r → %r for %s", user.role, canonical, user.email)
|
|
user.role = canonical
|
|
changed += 1
|
|
elif canonical is None and not is_blocked_legacy_role(user.role):
|
|
logger.warning(
|
|
"User %s has unrecognized role %r; login will be denied until fixed",
|
|
user.email,
|
|
user.role,
|
|
)
|
|
if changed:
|
|
await db.flush()
|
|
return changed
|
|
|
|
|
|
async def normalize_legacy_user_emails(db: AsyncSession) -> int:
|
|
"""Lowercase stored user emails to match the normalized login lookup.
|
|
|
|
Databases built before the P0 batch can hold mixed-case emails (the old
|
|
open self-registration stored them verbatim) while login now compares on
|
|
the lowercase form, so such rows would otherwise be silently locked out.
|
|
Rewrites each stored email to its stripped/lowercase form. When two rows
|
|
share an email that differs only in case, only the lowest-id row becomes
|
|
canonical (any others keep their stored value and are logged as a warning)
|
|
so the unique constraint is never violated. Idempotent; returns the
|
|
number of rows rewritten.
|
|
"""
|
|
result = await db.execute(select(User).order_by(User.id))
|
|
users = list(result.scalars().all())
|
|
groups: dict[str, list[User]] = {}
|
|
for user in users:
|
|
groups.setdefault(user.email.strip().lower(), []).append(user)
|
|
|
|
changed = 0
|
|
for normalized, members in groups.items():
|
|
if len(members) == 1:
|
|
user = members[0]
|
|
if user.email != normalized:
|
|
logger.info(
|
|
"Normalizing legacy email %r → %r for user %d", user.email, normalized, user.id
|
|
)
|
|
user.email = normalized
|
|
changed += 1
|
|
continue
|
|
if any(user.email == normalized for user in members):
|
|
losers = [u for u in members if u.email != normalized]
|
|
else:
|
|
winner = members[0]
|
|
logger.info(
|
|
"Normalizing legacy email %r → %r for user %d", winner.email, normalized, winner.id
|
|
)
|
|
winner.email = normalized
|
|
changed += 1
|
|
losers = members[1:]
|
|
for loser in losers:
|
|
logger.warning(
|
|
"Cannot normalize email %r for user %d: another account already holds "
|
|
"that normalized email; keeping the stored value",
|
|
normalized,
|
|
loser.id,
|
|
)
|
|
if changed:
|
|
await db.flush()
|
|
return changed
|
|
|
|
|
|
async def seed_users(db: AsyncSession, default_password: str = "denya123") -> list[User]:
|
|
"""Insert seed users if they don't already exist."""
|
|
hashed = hash_password(default_password)
|
|
created: list[User] = []
|
|
for data in SEED_USERS_DATA:
|
|
result = await db.execute(select(User).where(User.email == data["email"]))
|
|
if result.scalar_one_or_none() is None:
|
|
user = User(
|
|
email=data["email"],
|
|
password_hash=hashed,
|
|
full_name=data["full_name"],
|
|
phone=data["phone"],
|
|
role=data["role"],
|
|
)
|
|
db.add(user)
|
|
created.append(user)
|
|
if created:
|
|
await db.flush()
|
|
for u in created:
|
|
await db.refresh(u)
|
|
logger.info("Seeded %d users", len(created))
|
|
else:
|
|
logger.info("All seed users already exist")
|
|
return created
|
|
|
|
|
|
async def seed_units(db: AsyncSession, json_path: str | Path | None = None) -> list[Unit]:
|
|
"""Insert units from *apartment_mapping.json*.
|
|
|
|
Falls back to a small built-in set if the file is not found.
|
|
"""
|
|
units_data: list[dict] = []
|
|
|
|
if json_path:
|
|
p = Path(json_path)
|
|
if p.exists():
|
|
with open(p) as f:
|
|
raw = json.load(f)
|
|
units_data = raw if isinstance(raw, list) else raw.get("units", [])
|
|
logger.info("Loaded %d units from %s", len(units_data), p)
|
|
|
|
if not units_data:
|
|
# Built-in fallback for Pavilion Accra
|
|
logger.warning("apartment_mapping.json not found; using built-in fallback")
|
|
for wing in ("East", "West"):
|
|
for floor in range(1, 11):
|
|
for num in range(1, 7):
|
|
code = f"{floor:02d}{num}{wing[0]}"
|
|
units_data.append({
|
|
"apartment_code": code,
|
|
"property": wing,
|
|
"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:
|
|
code = entry.get("apartment_code") or entry.get("unit") or ""
|
|
if not code:
|
|
continue
|
|
result = await db.execute(select(Unit).where(Unit.apartment_code == code))
|
|
if result.scalar_one_or_none() is None:
|
|
unit = Unit(
|
|
property=entry.get("property", "East"),
|
|
apartment_code=code,
|
|
building=entry.get("building"),
|
|
floor=entry.get("floor"),
|
|
)
|
|
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:
|
|
await db.refresh(u)
|
|
logger.info("Seeded %d units", len(created))
|
|
else:
|
|
logger.info("All units already exist")
|
|
return created
|
|
|
|
|
|
# ── Category taxonomy (PRD §7) ───────────────────────────────────────
|
|
# Top-level categories with sub-categories
|
|
SEED_CATEGORIES_DATA: list[dict] = [
|
|
# ── Maintenance ──────────────────────────────────────────────
|
|
{"type": "maintenance", "name": "Plumbing", "subs": ["WC not flushing", "Leaky tap", "Pipe burst", "Blocked drain", "Water heater"]},
|
|
{"type": "maintenance", "name": "Electrical", "subs": ["No power", "Tripped breaker", "Light fitting", "Socket repair", "Voltage issues"]},
|
|
{"type": "maintenance", "name": "HVAC", "subs": ["AC not cooling", "AC leaking", "Thermostat", "Fan not working"]},
|
|
{"type": "maintenance", "name": "Furniture", "subs": ["Broken bed", "Broken chair", "Broken table", "Wardrobe issue"]},
|
|
{"type": "maintenance", "name": "Appliances", "subs": ["Fridge", "Washing machine", "Microwave", "TV", "Kettle"]},
|
|
{"type": "maintenance", "name": "Cleaning", "subs": ["Deep clean requested", "Post-checkout turnover", "Common area cleaning"]},
|
|
{"type": "maintenance", "name": "Pest Control", "subs": ["Insects", "Rodents", "Fumigation"]},
|
|
{"type": "maintenance", "name": "Security", "subs": ["Lock broken", "Door not closing", "Window latch", "CCTV issue"]},
|
|
{"type": "maintenance", "name": "Internet", "subs": ["WiFi down", "Slow speed", "Router reset"]},
|
|
{"type": "maintenance", "name": "Structural", "subs": ["Wall crack", "Ceiling leak", "Floor tile", "Paint touch-up"]},
|
|
{"type": "maintenance", "name": "Aluminum/Glass",
|
|
"subs": ["Window repair", "Window replacement", "Door repair (sliding)", "Door repair (fixed)",
|
|
"Glass replacement", "Aluminum frame repair", "Shower screen", "Mirror replacement"]},
|
|
{"type": "maintenance", "name": "Carpentry",
|
|
"subs": ["Door repair", "Door replacement", "Door frame", "Cabinet repair", "Wardrobe repair",
|
|
"Shelving", "Timber repair", "Loose hinge/lock plate"]},
|
|
{"type": "maintenance", "name": "Mould & Damp",
|
|
"subs": ["Wall mould", "Ceiling damp patch", "Bathroom mould", "Mould after leak",
|
|
"Damp proofing / remediation"],
|
|
"sla_urgency": "medium"},
|
|
# ── Customer Service ─────────────────────────────────────────
|
|
{"type": "cs", "name": "Check-in", "subs": ["Early check-in", "Key handover", "Welcome instructions"]},
|
|
{"type": "cs", "name": "Check-out", "subs": ["Late checkout", "Key return", "Inspection"]},
|
|
{"type": "cs", "name": "Housekeeping", "subs": ["Mid-stay cleaning", "Linen change", "Restocking"]},
|
|
{"type": "cs", "name": "Missing Item", "subs": ["Guest left items behind", "Item search request"]},
|
|
{"type": "cs", "name": "Billing", "subs": ["Invoice question", "Payment issue", "Deposit query"]},
|
|
{"type": "cs", "name": "Staff Behaviour", "subs": ["Staff conduct feedback"]},
|
|
# ── Phase 1 additions (maintenance) ──────────────────────────
|
|
{"type": "maintenance", "name": "Kitchen Sink", "subs": ["Clogging"]},
|
|
{"type": "maintenance", "name": "Painting", "subs": []},
|
|
{"type": "maintenance", "name": "Gym", "subs": []},
|
|
{"type": "maintenance", "name": "Swimming Pool", "subs": []},
|
|
{"type": "maintenance", "name": "Shower Cord", "subs": []},
|
|
{"type": "maintenance", "name": "Sliding Doors", "subs": []},
|
|
{"type": "maintenance", "name": "Sliding Windows", "subs": []},
|
|
{"type": "maintenance", "name": "Low Water Pressure", "subs": []},
|
|
{"type": "maintenance", "name": "Damages", "subs": []},
|
|
# ── Phase 1 additions (customer service) ─────────────────────
|
|
{"type": "cs", "name": "Parking Issues", "subs": []},
|
|
{"type": "cs", "name": "Noise Complaints", "subs": []},
|
|
{"type": "cs", "name": "Waste Management", "subs": []},
|
|
# ── Emergency ────────────────────────────────────────────────
|
|
{"type": "emergency", "name": "Fire", "subs": ["Smoke detected", "Fire alarm", "Sprinkler issue"], "sla_urgency": "urgent"},
|
|
{"type": "emergency", "name": "Flood", "subs": ["Major water leak", "Burst pipe", "Overflowing"], "sla_urgency": "urgent"},
|
|
{"type": "emergency", "name": "Gas Leak", "subs": ["Gas smell", "Suspected leak"], "sla_urgency": "urgent", "show_in_form": False},
|
|
{"type": "emergency", "name": "Electrical Hazard", "subs": ["Sparking", "Exposed wires", "Power outage multiple units"], "sla_urgency": "urgent"},
|
|
]
|
|
|
|
|
|
async def seed_categories(db: AsyncSession) -> list[Category]:
|
|
"""Seed the categories table with the full PRD §7 taxonomy.
|
|
|
|
Idempotent: only inserts missing rows. ``show_in_form`` on existing
|
|
parents is synced when the seed data explicitly sets it (e.g. Gas Leak
|
|
is alert-only, so already-seeded databases get flipped to False on the
|
|
next startup).
|
|
"""
|
|
created: list[Category] = []
|
|
|
|
for group in SEED_CATEGORIES_DATA:
|
|
# Check if parent category exists
|
|
result = await db.execute(
|
|
select(Category).where(
|
|
Category.type == group["type"],
|
|
Category.name == group["name"],
|
|
Category.parent_id.is_(None),
|
|
)
|
|
)
|
|
parent = result.scalar_one_or_none()
|
|
if parent is None:
|
|
parent = Category(
|
|
type=group["type"],
|
|
name=group["name"],
|
|
parent_id=None,
|
|
sla_urgency=group.get("sla_urgency"),
|
|
show_in_form=group.get("show_in_form", True),
|
|
)
|
|
db.add(parent)
|
|
await db.flush()
|
|
created.append(parent)
|
|
elif "show_in_form" in group and parent.show_in_form != group["show_in_form"]:
|
|
# Sync visibility for existing rows (e.g. Gas Leak → alert-only)
|
|
parent.show_in_form = group["show_in_form"]
|
|
await db.flush()
|
|
created.append(parent)
|
|
|
|
# Seed sub-categories
|
|
for sub_name in group["subs"]:
|
|
result = await db.execute(
|
|
select(Category).where(
|
|
Category.type == group["type"],
|
|
Category.name == sub_name,
|
|
Category.parent_id == parent.id,
|
|
)
|
|
)
|
|
existing = result.scalar_one_or_none()
|
|
if existing is None:
|
|
sub = Category(
|
|
type=group["type"],
|
|
name=sub_name,
|
|
parent_id=parent.id,
|
|
sla_urgency=None,
|
|
)
|
|
db.add(sub)
|
|
created.append(sub)
|
|
|
|
if created:
|
|
await db.flush()
|
|
for c in created:
|
|
await db.refresh(c)
|
|
logger.info("Seeded %d categories", len(created))
|
|
else:
|
|
logger.info("All seed categories already exist")
|
|
return created
|