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,287 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user