- 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]>
255 lines
8.2 KiB
Python
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)
|