Admin: Departments (name and stations; add shows the three groups IT must create), Profiles (welder stamp, station limits, active) with a CSV import that previews and applies only when clean, Checklists (weld and plain), Security (session, lockout and rate-limit settings with bounds, alerts for the last 24 hours, the integrity result), Access log with filters and a guarded CSV, Who is in, Stamp check, Notifications, and the employeeID flags. Every Admin change is audited. Worker: ad-reconcile (nightly and on "refresh from AD now"), session-refresh (15 minutes), integrity (seals, asset hashes, missing and orphan files, the audit chain), backup-check, notify-send and notify-digest. Notifications route per event type to AD groups, users, mailboxes or the department Supervisor group, immediately or in a daily digest, over STARTTLS verified against the PESCO CA, with dry run on the test stack. Backups: scripts/backup/ps-backup.sh and ps-restore.sh with a systemd timer at 02:00 America/Denver. Restore rehearsed on the test stack 2026-10-21. CI: pip-audit and npm audit. pypdf 6.21.1 and certifi 2026.10.19. Migration 0010. Closes the app part of I6, M3, O1, O2 and S3. Co-Authored-By: Claude Opus 5.5 <[email protected]>
267 lines
11 KiB
Python
267 lines
11 KiB
Python
"""Departments and their stations, as the Admin edits them.
|
|
|
|
Code and slug never change (the group names derive from them; a trigger refuses it too). Stations are never deleted,
|
|
because station limits and packets point at them: `active: false` hides one. A new department is a shop department
|
|
with the three shop groups IT must create in the PacketStation OU. The support departments map to fixed roles, so a
|
|
new one would have no group and no role; it is refused.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Any
|
|
|
|
from psycopg.rows import dict_row
|
|
|
|
from .. import audit
|
|
from ..api.errors import ApiError
|
|
from ..auth import groups as groupmap
|
|
|
|
RX_CODE = re.compile(r"^[0-9A-Z]{2,4}$")
|
|
RX_SLUG = re.compile(r"^[A-Za-z][A-Za-z0-9]{1,30}$")
|
|
NAME_MAX = 80
|
|
STATION_NAME_MAX = 60
|
|
STATIONS_MAX = 60
|
|
|
|
|
|
def clean_name(value: Any, what: str, limit: int) -> str:
|
|
text = " ".join(str(value or "").split())
|
|
if not text:
|
|
raise ApiError(422, "invalid_request", f"Give the {what} a name.")
|
|
if len(text) > limit:
|
|
raise ApiError(422, "invalid_request", f"The {what} name is longer than {limit} characters.")
|
|
return text
|
|
|
|
|
|
def group_names_for(code: str, slug: str, kind: str, supervisor_group: bool) -> list[tuple[str, str]]:
|
|
"""(group name, role) for one department, as the group map builds them."""
|
|
if kind == "shop":
|
|
return [(f"PS-{code}-{slug}-{lv.capitalize()}", f"shop:{code}:{lv}") for lv in groupmap.SHOP_LEVELS]
|
|
kinds = groupmap.OFFICE_KINDS.get(code)
|
|
if kinds is None:
|
|
return []
|
|
member, sup = kinds
|
|
out = [(f"PS-{slug}", member)]
|
|
if supervisor_group and sup:
|
|
out.append((f"PS-{slug}-Supervisor", sup))
|
|
return out
|
|
|
|
|
|
def _stations(conn: Any, code: str | None = None) -> dict[str, list[dict[str, Any]]]:
|
|
sql = "SELECT dept_code, id, name, sort, active FROM stations"
|
|
params: tuple[Any, ...] = ()
|
|
if code is not None:
|
|
sql += " WHERE dept_code = %s"
|
|
params = (code,)
|
|
out: dict[str, list[dict[str, Any]]] = {}
|
|
for dept, sid, name, sort, active in conn.execute(sql + " ORDER BY dept_code, sort, name, id", params):
|
|
out.setdefault(dept, []).append({"id": int(sid), "name": name, "sort": int(sort), "active": bool(active)})
|
|
return out
|
|
|
|
|
|
def _out(r: dict[str, Any], stations: list[dict[str, Any]], in_ou: set[str] | None) -> dict[str, Any]:
|
|
groups = [
|
|
{"name": name, "role": role, "in_ou": None if in_ou is None else name.lower() in in_ou}
|
|
for name, role in group_names_for(r["code"], r["slug"], r["kind"], bool(r["supervisor_group"]))
|
|
]
|
|
return {
|
|
"code": r["code"],
|
|
"slug": r["slug"],
|
|
"name": r["name"],
|
|
"kind": r["kind"],
|
|
"sort": int(r["sort"]),
|
|
"active": bool(r["active"]),
|
|
"weld": bool(r["weld"]),
|
|
"supervisor_group": bool(r["supervisor_group"]),
|
|
"created_at": r["created_at"],
|
|
"groups": groups,
|
|
"stations": stations,
|
|
}
|
|
|
|
|
|
SELECT = (
|
|
"SELECT code, slug, name, kind::text AS kind, sort, active, weld, supervisor_group, created_at FROM departments"
|
|
)
|
|
|
|
|
|
def list_all(conn: Any, in_ou: list[str] | None) -> list[dict[str, Any]]:
|
|
"""Every department, active or not, in sort order. `in_ou` is the group names in the OU (None: unknown)."""
|
|
lower = {n.lower() for n in in_ou} if in_ou is not None else None
|
|
with conn.cursor(row_factory=dict_row) as cur:
|
|
rows = cur.execute(SELECT + " ORDER BY sort, code").fetchall()
|
|
st = _stations(conn)
|
|
return [_out(r, st.get(r["code"], []), lower) for r in rows]
|
|
|
|
|
|
def get(conn: Any, code: str, in_ou: list[str] | None = None) -> dict[str, Any] | None:
|
|
lower = {n.lower() for n in in_ou} if in_ou is not None else None
|
|
with conn.cursor(row_factory=dict_row) as cur:
|
|
r = cur.execute(SELECT + " WHERE code = %s", (code,)).fetchone()
|
|
if r is None:
|
|
return None
|
|
return _out(r, _stations(conn, code).get(code, []), lower)
|
|
|
|
|
|
def _snapshot(d: dict[str, Any]) -> dict[str, Any]:
|
|
return {k: d[k] for k in ("name", "active", "stations")}
|
|
|
|
|
|
def update(
|
|
conn: Any,
|
|
who: Any,
|
|
code: str,
|
|
*,
|
|
name: str | None,
|
|
active: bool | None,
|
|
stations: list[dict[str, Any]] | None,
|
|
) -> dict[str, Any]:
|
|
"""Change the name, active and stations of one department. Audited `admin.department_updated`."""
|
|
with conn.transaction():
|
|
locked = conn.execute("SELECT code FROM departments WHERE code = %s FOR UPDATE", (code,)).fetchone()
|
|
if locked is None:
|
|
raise ApiError(404, "not_found", "No such department.")
|
|
before = get(conn, code)
|
|
assert before is not None
|
|
if name is not None:
|
|
conn.execute(
|
|
"UPDATE departments SET name = %s WHERE code = %s", (clean_name(name, "department", NAME_MAX), code)
|
|
)
|
|
if active is not None:
|
|
conn.execute("UPDATE departments SET active = %s WHERE code = %s", (bool(active), code))
|
|
if stations is not None:
|
|
_apply_stations(conn, code, before["stations"], stations)
|
|
after = get(conn, code)
|
|
assert after is not None
|
|
if _snapshot(before) != _snapshot(after):
|
|
audit.append(
|
|
conn,
|
|
action="admin.department_updated",
|
|
user_id=who.user_id,
|
|
session_id=who.session_id,
|
|
role_snapshot=who.role_snapshot,
|
|
subject_type="department",
|
|
subject_id=code,
|
|
detail={"before": _snapshot(before), "after": _snapshot(after)},
|
|
ip=who.ip,
|
|
)
|
|
return after
|
|
|
|
|
|
def _apply_stations(conn: Any, code: str, current: list[dict[str, Any]], wanted: list[dict[str, Any]]) -> None:
|
|
by_id = {s["id"]: s for s in current}
|
|
final: dict[int, dict[str, Any]] = {s["id"]: dict(s) for s in current}
|
|
new: list[dict[str, Any]] = []
|
|
seen_ids: set[int] = set()
|
|
for w in wanted:
|
|
sid = w.get("id")
|
|
entry = {
|
|
"name": clean_name(w.get("name"), "station", STATION_NAME_MAX),
|
|
"sort": int(w.get("sort") or 0),
|
|
"active": bool(w.get("active", True)),
|
|
}
|
|
if sid is None:
|
|
new.append(entry)
|
|
continue
|
|
if sid not in by_id:
|
|
raise ApiError(422, "invalid_request", f"Station {sid} is not in department {code}.")
|
|
if sid in seen_ids:
|
|
raise ApiError(422, "invalid_request", f"Station {sid} is listed twice.")
|
|
seen_ids.add(sid)
|
|
final[sid].update(entry)
|
|
names = [s["name"].lower() for s in final.values()] + [s["name"].lower() for s in new]
|
|
dupes = sorted({n for n in names if names.count(n) > 1})
|
|
if dupes:
|
|
raise ApiError(422, "duplicate_station", f"Two stations in {code} have the same name: {', '.join(dupes)}.")
|
|
if len(final) + len(new) > STATIONS_MAX:
|
|
raise ApiError(422, "invalid_request", f"A department has at most {STATIONS_MAX} stations.")
|
|
renamed = [sid for sid, s in final.items() if s["name"] != by_id[sid]["name"]]
|
|
# Names are unique per department: park the renamed ones first so two stations can swap names.
|
|
for sid in renamed:
|
|
conn.execute("UPDATE stations SET name = %s WHERE id = %s", (f"~{sid}~renaming", sid))
|
|
for sid, s in final.items():
|
|
if s != by_id[sid]:
|
|
conn.execute(
|
|
"UPDATE stations SET name = %s, sort = %s, active = %s WHERE id = %s",
|
|
(s["name"], s["sort"], s["active"], sid),
|
|
)
|
|
for s in new:
|
|
conn.execute(
|
|
"INSERT INTO stations (dept_code, name, sort, active) VALUES (%s, %s, %s, %s)",
|
|
(code, s["name"], s["sort"], s["active"]),
|
|
)
|
|
|
|
|
|
def add(
|
|
conn: Any,
|
|
who: Any,
|
|
*,
|
|
code: str,
|
|
slug: str,
|
|
name: str,
|
|
kind: str,
|
|
weld: bool,
|
|
supervisor_group: bool,
|
|
stations: list[str],
|
|
) -> tuple[dict[str, Any], list[str]]:
|
|
"""Add a shop department. Returns (department, group names IT must create). Audited `admin.department_added`."""
|
|
code, slug = code.strip(), slug.strip()
|
|
if not RX_CODE.match(code):
|
|
raise ApiError(422, "invalid_request", "The code is 2 to 4 capital letters or digits, like 15.")
|
|
if not RX_SLUG.match(slug):
|
|
raise ApiError(422, "invalid_request", "The short name is a letter, then letters or digits, no spaces.")
|
|
if kind != "shop":
|
|
raise ApiError(
|
|
422,
|
|
"support_fixed",
|
|
"Only a shop department can be added. The support departments map to fixed roles.",
|
|
)
|
|
title = clean_name(name, "department", NAME_MAX)
|
|
names: list[str] = []
|
|
for s in stations:
|
|
n = clean_name(s, "station", STATION_NAME_MAX)
|
|
if n.lower() in (x.lower() for x in names):
|
|
raise ApiError(422, "duplicate_station", f"Two stations have the same name: {n}.")
|
|
names.append(n)
|
|
if len(names) > STATIONS_MAX:
|
|
raise ApiError(422, "invalid_request", f"A department has at most {STATIONS_MAX} stations.")
|
|
with conn.transaction():
|
|
conn.execute("LOCK TABLE departments IN SHARE ROW EXCLUSIVE MODE")
|
|
if conn.execute("SELECT 1 FROM departments WHERE upper(code) = upper(%s)", (code,)).fetchone():
|
|
raise ApiError(409, "code_taken", f"Department code {code} is taken.")
|
|
if conn.execute("SELECT 1 FROM departments WHERE lower(slug) = lower(%s)", (slug,)).fetchone():
|
|
raise ApiError(409, "slug_taken", f"The short name {slug} is taken.")
|
|
sort = int(conn.execute("SELECT coalesce(max(sort), 0) + 10 FROM departments").fetchone()[0])
|
|
conn.execute(
|
|
"INSERT INTO departments (code, slug, name, kind, sort, active, supervisor_group, weld, created_at,"
|
|
" created_by) VALUES (%s, %s, %s, %s::dept_kind, %s, true, %s, %s, now(), %s)",
|
|
(code, slug, title, kind, sort, bool(supervisor_group), bool(weld), who.user_id),
|
|
)
|
|
for i, n in enumerate(names, start=1):
|
|
conn.execute("INSERT INTO stations (dept_code, name, sort) VALUES (%s, %s, %s)", (code, n, i))
|
|
dept = get(conn, code)
|
|
assert dept is not None
|
|
groups = [g["name"] for g in dept["groups"]]
|
|
audit.append(
|
|
conn,
|
|
action="admin.department_added",
|
|
user_id=who.user_id,
|
|
session_id=who.session_id,
|
|
role_snapshot=who.role_snapshot,
|
|
subject_type="department",
|
|
subject_id=code,
|
|
detail={
|
|
"after": {
|
|
"code": code,
|
|
"slug": slug,
|
|
"name": title,
|
|
"kind": kind,
|
|
"weld": bool(weld),
|
|
"supervisor_group": bool(supervisor_group),
|
|
"stations": names,
|
|
},
|
|
"groups_to_create": groups,
|
|
},
|
|
ip=who.ip,
|
|
)
|
|
return dept, groups
|