"""The derived group-to-role map. Roles come only from AD groups; nothing in the app can change the map. The department table is seed data (migration 0002). The map is built from it plus `PS-Admins`. `DEPARTMENTS` here mirrors the seed so the map works without a database (CLI, tests). """ from __future__ import annotations from collections.abc import Iterable, Sequence from dataclasses import dataclass from typing import Any ADMIN_GROUP = "PS-Admins" SHOP_LEVELS = ("worker", "lead", "supervisor") SCREENS = ("clerical", "warehouse", "supervisor", "stall", "qc", "pm", "admin") # Office role kinds per support department code: (member kind, supervisor kind or None) OFFICE_KINDS: dict[str, tuple[str, str | None]] = { "CL": ("clerical", "clerical_supervisor"), "WH": ("warehouse", "warehouse_supervisor"), "QC": ("qc", "qc_supervisor"), "PM": ("pm", None), } OFFICE_DEPT: dict[str, str] = {kind: code for code, kinds in OFFICE_KINDS.items() for kind in kinds if kind} ROLE_KINDS = ( "admin", "clerical", "clerical_supervisor", "warehouse", "warehouse_supervisor", "qc", "qc_supervisor", "pm", "shop_worker", "shop_lead", "shop_supervisor", ) @dataclass(frozen=True) class Department: code: str slug: str name: str kind: str # support | shop sort: int supervisor_group: bool = True active: bool = True def _stations(prefix: str, n: int) -> tuple[str, ...]: return tuple(f"{prefix} {i}" for i in range(1, n + 1)) # code, slug, name, kind, supervisor_group, stations. Mirrors the seed in migration 0002. SEED: tuple[tuple[str, str, str, str, bool, tuple[str, ...]], ...] = ( ("CL", "Clerical", "Clerical", "support", True, ()), ("WH", "Warehouse", "Warehouse", "support", True, ()), ("QC", "QC", "Q.C.", "support", True, ()), ("PM", "ProjectMgmt", "Project Management", "support", False, ()), ("01", "Layout", "Layout", "shop", True, _stations("Station", 4)), ("02", "SouthLayout", "PESCO South Layout", "shop", True, _stations("Station", 4)), ("03", "WeldParts", "Weld Parts", "shop", True, _stations("Stall", 6)), ("04", "SouthWeld", "PESCO South Weld", "shop", True, _stations("Stall", 6)), ("05", "SouthWeldSS", "PESCO South Weld SS", "shop", True, _stations("Stall", 6)), ("06", "CodeBldg2", "Code Bldg 2", "shop", True, _stations("Stall", 6)), ("07", "CodeBldg3", "Code Bldg 3", "shop", True, _stations("Stall", 6)), ("08", "Subassembly", "Subassembly", "shop", True, _stations("Station", 4)), ("09", "Hydro", "Hydro", "shop", True, ("Hydro Bay 1", "Hydro Bay 2")), ("10", "WeldAssembly", "Weld Assembly", "shop", True, _stations("Stall", 6)), ("11", "FinalAssyNorth", "Final Assembly - North", "shop", True, _stations("Station", 4)), ("12", "FinalAssySouth", "Final Assembly - South", "shop", True, _stations("Station", 4)), ("13", "PaintSandblast", "Paint-Sandblast", "shop", True, ("Blast Booth", "Paint Booth 1", "Paint Booth 2")), ("14", "Electrical", "Electrical", "shop", True, _stations("Station", 4)), ) DEPARTMENTS: tuple[Department, ...] = tuple( Department(code, slug, name, kind, (i + 1) * 10, sup) for i, (code, slug, name, kind, sup, _) in enumerate(SEED) ) @dataclass(frozen=True) class Role: """One role: a kind and its department code (None for admin).""" kind: str dept_code: str | None = None def __str__(self) -> str: if self.kind.startswith("shop_"): return f"shop:{self.dept_code}:{self.kind.removeprefix('shop_')}" return self.kind def parse_role(text: str) -> Role: """`"shop:03:supervisor"` -> Role("shop_supervisor", "03"); `"clerical"` -> Role("clerical", "CL").""" if text.startswith("shop:"): parts = text.split(":") if len(parts) != 3 or parts[2] not in SHOP_LEVELS or not parts[1]: raise ValueError(f"bad role {text!r}") return Role(f"shop_{parts[2]}", parts[1]) if text == "admin": return Role("admin", None) if text in OFFICE_DEPT: return Role(text, OFFICE_DEPT[text]) raise ValueError(f"bad role {text!r}") def role_from_row(kind: str, dept_code: str | None) -> str: """The role string for a `user_roles` row.""" return str(Role(kind, dept_code)) def _level(r: Role) -> int: return SHOP_LEVELS.index(r.kind.removeprefix("shop_")) class GroupMap: """Group name -> Role for a list of departments. Matching is case-insensitive on the cn.""" def __init__(self, departments: Sequence[Department]): self.departments = sorted(departments, key=lambda d: (0 if d.kind == "support" else 1, d.sort, d.code)) self.entries: list[tuple[str, Role]] = [(ADMIN_GROUP, Role("admin"))] for d in self.departments: if d.kind != "support" or d.code not in OFFICE_KINDS: continue member, sup = OFFICE_KINDS[d.code] self.entries.append((f"PS-{d.slug}", Role(member, d.code))) if d.supervisor_group and sup: self.entries.append((f"PS-{d.slug}-Supervisor", Role(sup, d.code))) for d in self.departments: if d.kind == "shop": for level in SHOP_LEVELS: self.entries.append((f"PS-{d.code}-{d.slug}-{level.capitalize()}", Role(f"shop_{level}", d.code))) self._by_lower = {name.lower(): (name, role) for name, role in self.entries} self._order = {str(role): i for i, (_, role) in enumerate(self.entries)} def names(self) -> list[str]: return [name for name, _ in self.entries] def role_for(self, name: str) -> Role | None: hit = self._by_lower.get(name.strip().lower()) return hit[1] if hit else None def canonical(self, name: str) -> str | None: """The map's spelling of a group name, or None when it is not mapped.""" hit = self._by_lower.get(name.strip().lower()) return hit[0] if hit else None def sort_roles(self, roles: Iterable[str]) -> list[str]: return sorted(set(roles), key=lambda s: (self._order.get(s, 10_000), s)) def roles(self, names: Iterable[str]) -> list[str]: """Role strings for a set of group names: the union, highest shop level per department.""" found = {r for n in names if (r := self.role_for(n)) is not None} best: dict[str, Role] = {} out: set[str] = set() for r in found: if r.kind.startswith("shop_"): code = r.dept_code or "" cur = best.get(code) if cur is None or _level(r) > _level(cur): best[code] = r else: out.add(str(r)) out.update(str(r) for r in best.values()) return self.sort_roles(out) _default = GroupMap(DEPARTMENTS) def current() -> GroupMap: return _default def use_departments(departments: Sequence[Department]) -> GroupMap: """Replace the process-wide map (the app calls this at startup with the departments table).""" global _default _default = GroupMap(departments) return _default def load_departments(conn: Any) -> list[Department]: rows = conn.execute( "SELECT code, slug, name, kind::text, sort, supervisor_group, active FROM departments ORDER BY sort, code" ).fetchall() return [Department(r[0], r[1], r[2], r[3], r[4], r[5], r[6]) for r in rows] def group_names() -> list[str]: """The mapped group names in contract order: PS-Admins, CL, WH, QC, PM, then shop 01..14.""" return _default.names() def role_for_group(name: str) -> Role | None: return _default.role_for(name) def roles_from_groups(names: Iterable[str]) -> list[str]: return _default.roles(names) def mapped_groups(names: Iterable[str]) -> list[str]: """The map's spelling of every mapped name in `names`, in map order, without duplicates.""" keep = {c for n in names if (c := _default.canonical(n))} return [n for n in _default.names() if n in keep] def screens_for(roles: Iterable[str]) -> list[str]: """The screens a role set may open. Admin gets every screen.""" have: set[str] = set() for r in roles: if r == "admin": have.update(SCREENS) elif r.startswith("clerical"): have.add("clerical") elif r.startswith("warehouse"): have.add("warehouse") elif r.startswith("qc"): have.add("qc") elif r == "pm": have.add("pm") elif r.startswith("shop:"): have.add("stall") if r.endswith((":lead", ":supervisor")): have.add("supervisor") return [s for s in SCREENS if s in have] # The label of a screen, shown beside a name in presence ("Also in this packet now: Ann (Q.C.)"). SCREEN_LABELS: dict[str, str] = { "clerical": "Clerical", "warehouse": "Warehouse", "supervisor": "Supervisor", "stall": "Weld Stall", "qc": "Q.C.", "pm": "Project Mgmt", "admin": "Admin", } def _role_text(role: str) -> str: if role == "admin": return "Admin" if role.startswith("clerical"): return "Clerical" if role.startswith("warehouse"): return "Warehouse" if role.startswith("qc"): return "Q.C." if role == "pm": return "Project Mgmt" if role.startswith("shop:"): return "Supervisor" if role.endswith((":lead", ":supervisor")) else "Weld Stall" return "" def role_label(roles: Iterable[str]) -> str: """The person's primary role as a screen label: the first role in map order (admin, Clerical, Warehouse, Q.C., PM, then the shop departments). Empty when there is none.""" for r in _default.sort_roles(roles): text = _role_text(r) if text: return text return "" def is_admin(roles: Iterable[str]) -> bool: return "admin" in set(roles) def is_shop_supervisor(roles: Iterable[str]) -> bool: return any(r.startswith("shop:") and r.endswith(":supervisor") for r in roles)