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]>
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user