Files
PacketStation/server/packetstation/api/admin_org.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

288 lines
9.8 KiB
Python

"""Admin: departments and stations, people's profiles (one at a time and the CSV import), checklist templates.
Every route needs the admin role and a password session. Every change is audited (`admin.*`).
"""
from __future__ import annotations
import re
import uuid
from typing import Any
from fastapi import APIRouter, Depends, Request
from fastapi.encoders import jsonable_encoder
from psycopg.types.json import Jsonb
from pydantic import BaseModel, ConfigDict, Field
from .. import audit
from ..auth import groups as groupmap
from ..domain import checklists, profiles
from ..domain import departments as depts
from .admin import groups_in_ou
from .deps import Db, Principal, admin_principal, get_db
from .errors import ApiError
from .packets import WRITE
router = APIRouter(prefix="/api/admin", tags=["admin"])
IMPORT_MAX_BODY = profiles.CSV_MAX_BYTES * 2 + 10_000 # JSON escaping can double the text
def import_limit(request: Request) -> None:
length = request.headers.get("content-length")
if length is not None and length.strip().isdigit() and int(length) > IMPORT_MAX_BODY:
raise ApiError(413, "too_large", "The file is larger than 1 MB.")
def reload_groups(request: Request, conn: Any) -> None:
"""Rebuild the group map from the departments table and drop the cached OU listing."""
found = groupmap.load_departments(conn)
if found:
groupmap.use_departments(found)
request.app.state.groups_cache = None
# ---------------------------------------------------------------- departments
class StationIn(BaseModel):
model_config = ConfigDict(extra="forbid")
id: int | None = None
name: str = Field(max_length=200)
sort: int = Field(0, ge=0, le=100_000)
active: bool = True
class DepartmentPatch(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str | None = Field(None, max_length=200)
active: bool | None = None
stations: list[StationIn] | None = Field(None, max_length=depts.STATIONS_MAX)
class DepartmentNew(BaseModel):
model_config = ConfigDict(extra="forbid")
code: str = Field(max_length=10)
slug: str = Field(max_length=40)
name: str = Field(max_length=200)
kind: str = Field(pattern="^(shop|support)$")
weld: bool = False
supervisor_group: bool = True
stations: list[str] = Field(default_factory=list, max_length=depts.STATIONS_MAX)
@router.get("/departments")
def department_list(
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> list[dict[str, Any]]:
return depts.list_all(d.conn, groups_in_ou(request))
@router.patch("/departments/{code}", dependencies=WRITE)
def department_update(
code: str,
body: DepartmentPatch,
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
conn = d.conn
depts.update(
conn,
p,
code[:10],
name=body.name,
active=body.active,
stations=[s.model_dump() for s in body.stations] if body.stations is not None else None,
)
reload_groups(request, conn)
out = depts.get(conn, code[:10], groups_in_ou(request))
assert out is not None
return out
@router.post("/departments", status_code=201, dependencies=WRITE)
def department_add(
body: DepartmentNew,
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
conn = d.conn
_, groups = depts.add(
conn,
p,
code=body.code,
slug=body.slug,
name=body.name,
kind=body.kind,
weld=body.weld,
supervisor_group=body.supervisor_group,
stations=body.stations,
)
reload_groups(request, conn)
dept = depts.get(conn, body.code.strip(), groups_in_ou(request))
return {"department": dept, "groups_to_create": groups}
# ---------------------------------------------------------------- profiles
class ProfilePatch(BaseModel):
model_config = ConfigDict(extra="forbid")
welder_stamp: str | None = Field(None, max_length=40)
profile_active: bool | None = None
station_limits: dict[str, list[int]] | None = None
class ImportBody(BaseModel):
model_config = ConfigDict(extra="forbid")
csv: str
apply: bool = False
@router.patch("/users/{user_id}/profile", dependencies=WRITE)
def profile_update(
user_id: uuid.UUID,
body: ProfilePatch,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
fields = body.model_dump(exclude_unset=True)
out = profiles.update(d.conn, p, user_id, fields)
return {"user_id": str(user_id), **out}
@router.post("/users/import", dependencies=[Depends(import_limit)])
def profile_import(
body: ImportBody,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
return profiles.import_profiles(d.conn, p, body.csv, apply=body.apply)
# ---------------------------------------------------------------- checklists
RX_BRACES = re.compile(r"\{[^{}]*\}")
ALLOWED_PLACEHOLDERS = ("{drawing}", "{rev}", "{wps}")
class ChecklistsPut(BaseModel):
model_config = ConfigDict(extra="forbid")
value: dict[str, Any]
version: int = Field(ge=0)
def check_templates(value: Any) -> tuple[dict[str, list[dict[str, str]]], list[str]]:
"""The checklist templates cleaned, and one error line per problem."""
errors: list[str] = []
out: dict[str, list[dict[str, str]]] = {}
if not isinstance(value, dict):
return {}, ["value must be an object with weld and plain lists"]
extra = sorted(k for k in value if k not in ("weld", "plain"))
if extra:
errors.append(f"unknown list: {', '.join(extra)}")
for name in ("weld", "plain"):
items = value.get(name)
if not isinstance(items, list) or not items:
errors.append(f"{name}: at least one item")
continue
if len(items) > checklists.ITEMS_MAX:
errors.append(f"{name}: at most {checklists.ITEMS_MAX} items")
seen: set[str] = set()
clean: list[dict[str, str]] = []
for i, it in enumerate(items, start=1):
where = f"{name} item {i}"
if not isinstance(it, dict):
errors.append(f"{where}: must be an object with group, key and text")
continue
group = " ".join(str(it.get("group") or "").split())
key = str(it.get("key") or "").strip()
text = " ".join(str(it.get("text") or "").split())
if not checklists.RX_KEY.match(key):
errors.append(f"{where}: the key is a lower-case letter, then letters, digits or _ (at most 30)")
elif key in seen:
errors.append(f"{where}: key {key} is used twice")
seen.add(key)
if not group:
errors.append(f"{where}: the group is empty")
if not text:
errors.append(f"{where}: the text is empty")
if len(group) > checklists.TEXT_MAX or len(text) > checklists.TEXT_MAX:
errors.append(f"{where}: at most {checklists.TEXT_MAX} characters")
bad = [m for m in RX_BRACES.findall(text) if m not in ALLOWED_PLACEHOLDERS]
stray = RX_BRACES.sub("", text)
if bad:
errors.append(f"{where}: unknown placeholder {', '.join(bad)} (use {{drawing}}, {{rev}} or {{wps}})")
elif "{" in stray or "}" in stray:
errors.append(f"{where}: a brace without its pair")
clean.append({"group": group, "key": key, "text": text})
out[name] = clean
return out, errors
def _checklists_out(conn: Any) -> dict[str, Any]:
row = conn.execute("SELECT version FROM settings WHERE key = %s", (checklists.SETTINGS_KEY,)).fetchone()
return {
"value": checklists.load_templates(conn),
"defaults": checklists.DEFAULT_TEMPLATES,
"version": int(row[0]) if row else 0,
}
@router.get("/checklists")
def checklists_get(p: Principal = Depends(admin_principal), d: Db = Depends(get_db)) -> dict[str, Any]: # noqa: B008
return _checklists_out(d.conn)
@router.put("/checklists", dependencies=WRITE)
def checklists_put(
body: ChecklistsPut,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
conn = d.conn
value, errors = check_templates(body.value)
if errors:
raise ApiError(422, "bad_checklists", errors[0], extra={"errors": errors})
with conn.transaction():
row = conn.execute(
"SELECT version FROM settings WHERE key = %s FOR UPDATE", (checklists.SETTINGS_KEY,)
).fetchone()
current = int(row[0]) if row else 0
if body.version != current:
raise ApiError(
409,
"version_conflict",
"Someone else changed the checklists. Reload and try again.",
extra={"current": jsonable_encoder(_checklists_out(conn))},
)
before = checklists.load_templates(conn)
conn.execute(
"INSERT INTO settings (key, value, version, updated_at, updated_by) VALUES (%s, %s, 1, now(), %s)"
" ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, version = settings.version + 1,"
" updated_at = now(), updated_by = EXCLUDED.updated_by",
(checklists.SETTINGS_KEY, Jsonb(value), p.user_id),
)
audit.append(
conn,
action="admin.checklists_updated",
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
subject_type="settings",
subject_id=checklists.SETTINGS_KEY,
detail={"before": before, "after": value},
ip=p.ip,
)
return _checklists_out(conn)