2026-10-16 07:40:29 -06:00
|
|
|
"""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]
|
|
|
|
|
|
|
|
|
|
|
2026-10-18 15:08:34 -06:00
|
|
|
# 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 ""
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 07:40:29 -06:00
|
|
|
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)
|