Files

267 lines
11 KiB
Python
Raw Permalink Normal View History

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