288 lines
9.8 KiB
Python
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)
|