156 lines
5.9 KiB
Python
156 lines
5.9 KiB
Python
"""Per-department checklists (change C8, Gitea issue 23).
|
|||
|
|
|
||
|
|
Every signed-in session reads them. A department's supervisor edits that department's list and Admin edits any;
|
||
|
|
a lead, a worker, Q.C. and the office roles do not. A save needs a password session, carries the version it started
|
||
|
|
from (`base`, or If-Match) and writes one audit row (`checklists.dept_updated`). A change reaches only stages that
|
||
|
|
have not started: a stage freezes its list at START. `items: null` sets the department back to the default for its
|
||
|
|
kind (Admin's weld or plain list at /api/admin/checklists). The Q.C. check items are not here.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
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, policy
|
||
|
|
from ..domain import checklists, packets
|
||
|
|
from ..policy.custody import SUPERVISOR
|
||
|
|
from .deps import Db, Principal, current_principal, get_db, require_password_session
|
||
|
|
from .errors import ApiError
|
||
|
|
from .packets import WRITE
|
||
|
|
|
||
|
|
router = APIRouter(prefix="/api/checklists", tags=["checklists"])
|
||
|
|
|
||
|
|
MSG_NOT_YOURS = "Only this department's supervisor or an Admin can change its checklist."
|
||
|
|
MSG_STALE = "Someone else changed this checklist. Reload and try again."
|
||
|
|
MSG_NO_DEPT = "No such shop department."
|
||
|
|
|
||
|
|
|
||
|
|
class DeptChecklistPut(BaseModel):
|
||
|
|
model_config = ConfigDict(extra="forbid")
|
||
|
|
|
||
|
|
items: list[Any] | None = Field(max_length=checklists.ITEMS_MAX * 2)
|
||
|
|
base: int | None = Field(None, ge=0)
|
||
|
|
|
||
|
|
|
||
|
|
def can_edit(pp: policy.Principal, dept_code: str) -> bool:
|
||
|
|
"""Admin, or the department's Supervisor (a lead is not enough). Q.C. never edits a department list."""
|
||
|
|
return pp.admin or pp.level(dept_code) >= SUPERVISOR
|
||
|
|
|
||
|
|
|
||
|
|
def _shop_departments(conn: Any) -> list[dict[str, Any]]:
|
||
|
|
return packets.rows(
|
||
|
|
conn,
|
||
|
|
"SELECT code, name, weld FROM departments WHERE kind = 'shop' AND active ORDER BY sort, code",
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _dept(conn: Any, code: str) -> dict[str, Any]:
|
||
|
|
d = packets.one(conn, "SELECT code, name, weld FROM departments WHERE code = %s AND kind = 'shop'", (code,))
|
||
|
|
if d is None:
|
||
|
|
raise ApiError(404, "not_found", MSG_NO_DEPT)
|
||
|
|
return d
|
||
|
|
|
||
|
|
|
||
|
|
def _summary(conn: Any, pp: policy.Principal, d: dict[str, Any]) -> dict[str, Any]:
|
||
|
|
own, version = checklists.load_dept(conn, d["code"])
|
||
|
|
return {
|
||
|
|
"dept_code": d["code"],
|
||
|
|
"dept_name": d["name"],
|
||
|
|
"weld": bool(d["weld"]),
|
||
|
|
"custom": own is not None,
|
||
|
|
"version": version,
|
||
|
|
"editable": can_edit(pp, d["code"]),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _one(conn: Any, pp: policy.Principal, d: dict[str, Any]) -> dict[str, Any]:
|
||
|
|
out = _summary(conn, pp, d)
|
||
|
|
out["items"] = checklists.live_list(conn, d["code"], bool(d["weld"]))
|
||
|
|
out["defaults"] = checklists.default_for(conn, bool(d["weld"]))
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
@router.get("")
|
||
|
|
def list_checklists(
|
||
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
||
|
|
d: Db = Depends(get_db), # noqa: B008
|
||
|
|
) -> list[dict[str, Any]]:
|
||
|
|
"""Every active shop department: whether it has its own list, the version and whether the caller may edit."""
|
||
|
|
conn = d.conn
|
||
|
|
pp = packets.policy_principal_for(conn, p)
|
||
|
|
return [_summary(conn, pp, x) for x in _shop_departments(conn)]
|
||
|
|
|
||
|
|
|
||
|
|
@router.get("/{dept}")
|
||
|
|
def get_checklist(
|
||
|
|
dept: str,
|
||
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
||
|
|
d: Db = Depends(get_db), # noqa: B008
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
"""One department's live list (its own, else the default for its kind), unfilled: {drawing}, {rev} and {wps}
|
||
|
|
stay as placeholders. `defaults` is the default for its kind."""
|
||
|
|
conn = d.conn
|
||
|
|
pp = packets.policy_principal_for(conn, p)
|
||
|
|
return _one(conn, pp, _dept(conn, dept[:10]))
|
||
|
|
|
||
|
|
|
||
|
|
@router.put("/{dept}", dependencies=WRITE)
|
||
|
|
def put_checklist(
|
||
|
|
dept: str,
|
||
|
|
body: DeptChecklistPut,
|
||
|
|
request: Request,
|
||
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
||
|
|
d: Db = Depends(get_db), # noqa: B008
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
conn = d.conn
|
||
|
|
pp = packets.policy_principal_for(conn, p)
|
||
|
|
row = _dept(conn, dept[:10])
|
||
|
|
code = str(row["code"])
|
||
|
|
if not can_edit(pp, code):
|
||
|
|
raise ApiError(403, "forbidden", MSG_NOT_YOURS)
|
||
|
|
require_password_session(p)
|
||
|
|
base = body.base
|
||
|
|
if base is None:
|
||
|
|
tag = (request.headers.get("if-match") or "").strip().strip('"').removeprefix("W/").strip('"')
|
||
|
|
if not tag.isdigit():
|
||
|
|
raise ApiError(428, "if_match_required", "Send the version you started from.")
|
||
|
|
base = int(tag)
|
||
|
|
value: dict[str, Any]
|
||
|
|
if body.items is None:
|
||
|
|
value = {"items": None}
|
||
|
|
else:
|
||
|
|
clean, errors = checklists.check_list("checklist", body.items)
|
||
|
|
if errors or clean is None:
|
||
|
|
raise ApiError(422, "bad_checklist", errors[0], extra={"errors": errors})
|
||
|
|
value = {"items": clean}
|
||
|
|
key = checklists.dept_key(code)
|
||
|
|
with conn.transaction():
|
||
|
|
cur = conn.execute("SELECT version FROM settings WHERE key = %s FOR UPDATE", (key,)).fetchone()
|
||
|
|
current = int(cur[0]) if cur else 0
|
||
|
|
if base != current:
|
||
|
|
raise ApiError(409, "version_conflict", MSG_STALE, extra={"current": jsonable_encoder(_one(conn, pp, row))})
|
||
|
|
before = checklists.load_dept(conn, code)[0]
|
||
|
|
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",
|
||
|
|
(key, Jsonb(value), p.user_id),
|
||
|
|
)
|
||
|
|
audit.append(
|
||
|
|
conn,
|
||
|
|
action="checklists.dept_updated",
|
||
|
|
user_id=p.user_id,
|
||
|
|
session_id=p.session_id,
|
||
|
|
role_snapshot=p.role_snapshot,
|
||
|
|
subject_type="settings",
|
||
|
|
subject_id=key,
|
||
|
|
detail={"dept": code, "before": before, "after": value["items"]},
|
||
|
|
ip=p.ip,
|
||
|
|
)
|
||
|
|
return _one(conn, pp, row)
|