Files
PacketStation/server/packetstation/auth/groups.py
T

237 lines
8.7 KiB
Python
Raw Normal View History

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