Files
spencermandClaude Opus 5.5 70a97398f1
ci / server (pull_request) Successful in 12m48s
ci / frontend (pull_request) Successful in 54s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 4m38s
Build Seth's changes C1 to C11 (Gitea issues 1, 8, 18, 23, 24, 25, 27, 31, 32, 33, 36)
- C1 (issue 1): over target follows VISUAL ticket hours, not the timer.
- C2 (issue 24): a Q.C. reject always needs a reason; accept needs an NCR number after a reject without one.
- C3 (issue 25): an incomplete unit ITP blocks filing.
- C4 (issue 27): prints open the print dialog on the page, no new tab.
- C5 (issue 31): filing is blocked while VISUAL shows a work order open; the feed sets vclosed on VISUAL packets.
- C6 (issue 8): the data book leaves out the history pages.
- C7 (issue 18): the supervisor approves each received short; accept and filing wait for it.
- C8 (issue 23): per-department checklists, edited by the department supervisor or Admin, frozen at START.
- C9 (issue 32): Q.C. and Clerical may run the stamp check.
- C10 (issue 33): the PM screen and reports are for PS-ProjectMgmt and Admin only.
- C11 (issue 36): the Stall screen lists the packets assigned to the welder, at the supervisor's stall.

Migration 0011. Docs: PLAN (also issue 9 confirmed, issue 4 asked), POLICY, SECURITY, SCHEMA, FEED, README, CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 16:36:49 -06:00

255 lines
8.2 KiB
Python

"""Admin: departments and stations, people's profiles (one at a time and the CSV import), the default checklists by
kind (weld and plain; each department's own list is at /api/checklists, change C8).
Every route needs the admin role and a password session. Every change is audited (`admin.*`).
"""
from __future__ import annotations
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
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 default checklists by kind (weld and plain) cleaned, and one error line per problem. A department with
its own list (change C8, /api/checklists) does not read these."""
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"):
clean, errs = checklists.check_list(name, value.get(name))
errors += errs
if clean is not None:
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)