Files
PacketStation/server/packetstation/domain/departments.py
T
spencermandClaude Opus 5.5 a82e056b6a
ci / frontend (pull_request) Successful in 1m11s
ci / server (pull_request) Successful in 12m2s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 6m1s
Phase 9: Admin completion, reconcile, notifications, integrity, backups
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]>
2026-10-21 18:27:09 -06:00

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