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]>
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
"""Admin: departments and stations, people's profiles (one at a time and the CSV import), checklist templates.
|
||||
"""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 re
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
@@ -171,9 +171,6 @@ def profile_import(
|
||||
|
||||
# ---------------------------------------------------------------- checklists
|
||||
|
||||
RX_BRACES = re.compile(r"\{[^{}]*\}")
|
||||
ALLOWED_PLACEHOLDERS = ("{drawing}", "{rev}", "{wps}")
|
||||
|
||||
|
||||
class ChecklistsPut(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
@@ -183,7 +180,8 @@ class ChecklistsPut(BaseModel):
|
||||
|
||||
|
||||
def check_templates(value: Any) -> tuple[dict[str, list[dict[str, str]]], list[str]]:
|
||||
"""The checklist templates cleaned, and one error line per problem."""
|
||||
"""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):
|
||||
@@ -192,41 +190,10 @@ def check_templates(value: Any) -> tuple[dict[str, list[dict[str, str]]], list[s
|
||||
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
|
||||
clean, errs = checklists.check_list(name, value.get(name))
|
||||
errors += errs
|
||||
if clean is not None:
|
||||
out[name] = clean
|
||||
return out, errors
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Admin: the Security tab (settings, alerts, the integrity result), the access log and its CSV, who is in now, the
|
||||
stamp check, and the directory flags.
|
||||
|
||||
Every route needs the admin role and a password session. Every change is audited (`admin.*`).
|
||||
Every route needs the admin role and a password session, except the stamp check: Admin, Q.C. and Clerical on a password
|
||||
session, at /api/stamp-verify (change C9) and at the old /api/admin paths. Every change is audited (`admin.*`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -26,12 +27,23 @@ from ..auth import secsettings
|
||||
from ..domain import security_alerts
|
||||
from ..pdf import stamps, tracecsv
|
||||
from .account import LOG_PREFIXES
|
||||
from .deps import Db, Principal, admin_principal, get_db, idle_minutes, settings_of
|
||||
from .deps import (
|
||||
Db,
|
||||
Principal,
|
||||
admin_principal,
|
||||
current_principal,
|
||||
get_db,
|
||||
idle_minutes,
|
||||
require_password_session,
|
||||
settings_of,
|
||||
)
|
||||
from .errors import ApiError
|
||||
from .jobs import run_out
|
||||
from .packets import WRITE
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
# The stamp check outside /admin (change C9): Admin, Q.C. and Clerical. The /api/admin paths stay as aliases.
|
||||
stamp_router = APIRouter(prefix="/api", tags=["stamp"])
|
||||
|
||||
CSV = "text/csv; charset=utf-8"
|
||||
PDF_MAX_BYTES = 25 * 1024 * 1024
|
||||
@@ -499,6 +511,14 @@ def check_stamp(conn: Any, key: bytes | None, sig: str | None, shown: str | None
|
||||
return out
|
||||
|
||||
|
||||
def stamp_principal(p: Principal = Depends(current_principal)) -> Principal: # noqa: B008
|
||||
"""Admin, every Q.C. role and every Clerical role (change C9, Gitea issue 32), on a password session as the Admin
|
||||
path always needed."""
|
||||
if not (p.is_admin or any(r.startswith(("qc", "clerical")) for r in p.roles)):
|
||||
raise ApiError(403, "forbidden", "The stamp check is for Admin, Q.C. and Clerical.")
|
||||
return require_password_session(p)
|
||||
|
||||
|
||||
def _seal_key(request: Request) -> bytes | None:
|
||||
try:
|
||||
return settings_of(request).seal_key()
|
||||
@@ -526,10 +546,11 @@ def _audit_verify(conn: Any, p: Principal, results: list[dict[str, Any]], source
|
||||
|
||||
|
||||
@router.post("/stamp-verify", dependencies=WRITE)
|
||||
@stamp_router.post("/stamp-verify", dependencies=WRITE)
|
||||
def stamp_verify(
|
||||
body: StampText,
|
||||
request: Request,
|
||||
p: Principal = Depends(admin_principal), # noqa: B008
|
||||
p: Principal = Depends(stamp_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
text = body.text
|
||||
@@ -569,9 +590,10 @@ def pdf_stamps(data: bytes) -> list[tuple[str, str]]:
|
||||
|
||||
|
||||
@router.post("/stamp-verify/pdf")
|
||||
@stamp_router.post("/stamp-verify/pdf")
|
||||
async def stamp_verify_pdf(
|
||||
request: Request,
|
||||
p: Principal = Depends(admin_principal), # noqa: B008
|
||||
p: Principal = Depends(stamp_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
length = request.headers.get("content-length")
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""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)
|
||||
@@ -170,6 +170,11 @@ class ReceiptBody(It):
|
||||
note: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
class ShortApproveBody(It):
|
||||
sign: SignBody | None = None
|
||||
note: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
class WeldBody(It):
|
||||
page: int | None = Field(default=None, ge=1, le=100_000)
|
||||
fx: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
|
||||
@@ -880,6 +885,75 @@ def receive_short(
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
def _not_approved(conn: Any, receipt_id: int) -> None:
|
||||
if conn.execute("SELECT 1 FROM short_approvals WHERE receipt_id = %s", (receipt_id,)).fetchone():
|
||||
raise ApiError(409, "already_approved", "This receipt is already approved.")
|
||||
|
||||
|
||||
@router.post("/{packet_id}/shorts/receipts/{receipt_id}/approve", dependencies=WRITE)
|
||||
def approve_short_receipt(
|
||||
packet_id: int,
|
||||
receipt_id: int,
|
||||
body: ShortApproveBody,
|
||||
request: Request,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""The department supervisor approves one received short (C7): the part and its heat number match what the
|
||||
warehouse issued. One approval per receipt; a second answers 409 already_approved."""
|
||||
note = clean_opt(body.note, LIMITS["note"])
|
||||
pp = _pp(d, p)
|
||||
conn = d.conn
|
||||
receipt = packets.one(
|
||||
conn,
|
||||
"SELECT r.*, b.item FROM short_receipts r JOIN bom_shorts s ON s.id = r.short_id"
|
||||
" JOIN bom_lines b ON b.id = s.bom_line_id WHERE r.id = %s AND r.packet_id = %s",
|
||||
(receipt_id, packet_id),
|
||||
)
|
||||
if receipt is None:
|
||||
raise ApiError(404, "not_found", "No such short receipt on this packet.")
|
||||
_not_approved(conn, receipt_id) # before the signature: a second approval costs no attempt
|
||||
content = {
|
||||
"action": str(A.SHORT_APPROVE),
|
||||
"packet_id": packet_id,
|
||||
"receipt_id": receipt_id,
|
||||
"item": receipt["item"],
|
||||
"qty": qty_text(receipt["qty"]),
|
||||
"heat": receipt["heat"],
|
||||
"note": note,
|
||||
}
|
||||
with packet_write(
|
||||
d,
|
||||
p,
|
||||
packet_id,
|
||||
A.SHORT_APPROVE,
|
||||
request=request,
|
||||
it_reason=body.it_reason,
|
||||
sign=body.sign,
|
||||
sign_content=content,
|
||||
policy_principal=pp,
|
||||
) as w:
|
||||
_not_approved(conn, receipt_id) # again under the packet lock: a racing approval waits, then sees the first
|
||||
conn.execute(
|
||||
"INSERT INTO short_approvals (receipt_id, short_id, packet_id, dept_code, note, signature_id, by)"
|
||||
" VALUES (%s, %s, %s, %s, %s, %s, %s)",
|
||||
(
|
||||
receipt_id,
|
||||
receipt["short_id"],
|
||||
packet_id,
|
||||
w.state.dept,
|
||||
note,
|
||||
w.signature.id if w.signature else None,
|
||||
w.user_id,
|
||||
),
|
||||
)
|
||||
w.changed("bom.short")
|
||||
w.detail.update({"item": receipt["item"], "receipt_id": receipt_id})
|
||||
parts = [f"{qty_text(receipt['qty'])} pc", receipt["heat"] and f"heat {receipt['heat']}", note]
|
||||
w.log(f"Short approved · item {receipt['item']}", " · ".join(x for x in parts if x))
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- welds
|
||||
|
||||
|
||||
|
||||
@@ -115,7 +115,6 @@ class TransitionBody(BaseModel):
|
||||
location: str | None = Field(default=None, max_length=500)
|
||||
for_sup_id: uuid.UUID | None = None
|
||||
ncr: str | None = Field(default=None, max_length=100)
|
||||
confirm_open_wos: bool | None = None
|
||||
sign: SignBody | None = None
|
||||
it_reason: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
@@ -539,9 +538,9 @@ TRANSITION_FIELDS: dict[policy.Action, tuple[str, ...]] = {
|
||||
A.SEND_TO_QC: ("book", "spool", "location", "note"),
|
||||
A.ROUTE_FROM_REVIEW: ("dept", "note"),
|
||||
A.QC_REJECT: ("reason", "ncr"),
|
||||
A.QC_ACCEPT: ("routed_to", "note"),
|
||||
A.QC_ACCEPT_AND_ROUTE: ("dept", "note"),
|
||||
A.FILE: ("note", "confirm_open_wos"),
|
||||
A.QC_ACCEPT: ("routed_to", "note", "ncr"),
|
||||
A.QC_ACCEPT_AND_ROUTE: ("dept", "note", "ncr"),
|
||||
A.FILE: ("note",),
|
||||
A.ADMIN_REOPEN: ("reason",),
|
||||
}
|
||||
DEPT_ACTIONS = (A.RELEASE_TO_SHOP, A.ROUTE_FROM_REVIEW, A.QC_ACCEPT_AND_ROUTE)
|
||||
@@ -580,6 +579,8 @@ def transition(
|
||||
extra["for_sup_id"] = active_user(conn, extra["for_sup_id"], "supervisor")
|
||||
if action is A.QC_REJECT:
|
||||
_reject_reason(extra)
|
||||
elif "ncr" in extra:
|
||||
_accept_ncr(extra)
|
||||
if "stamp" in extra:
|
||||
# The assign sheet's stamp overrides the welder's own for this assignment; empty means use theirs.
|
||||
stamp = clean_text(extra["stamp"], 40)
|
||||
@@ -617,20 +618,25 @@ def transition(
|
||||
|
||||
|
||||
def _reject_reason(extra: dict[str, Any]) -> None:
|
||||
"""A Q.C. reject takes a reason, an NCR number, or both. The NCR stands in for the reason text: the reason the
|
||||
policy checks is "NCR-2026-0021" alone, or "NCR-2026-0021 · <reason>". Without an NCR the reason needs its usual
|
||||
8 characters."""
|
||||
"""A Q.C. reject always needs a detailed reason (8 characters or more, C2); an NCR number does not stand in for
|
||||
it. The reject may carry an NCR number too: the return note then reads "NCR-2026-0021 · <reason>"."""
|
||||
text = clean_text(extra.pop("reason", None), LIMITS["reason"])
|
||||
raw = clean_text(extra.pop("ncr", None), 100)
|
||||
ncr = ncrs.validate_ncr(raw) if raw else None
|
||||
if ncr:
|
||||
extra["ncr"] = ncr
|
||||
extra["reason"] = f"{ncr} · {text}" if text else ncr
|
||||
elif text:
|
||||
if text:
|
||||
extra["reason"] = text
|
||||
extra["reason_text"] = text
|
||||
|
||||
|
||||
def _accept_ncr(extra: dict[str, Any]) -> None:
|
||||
"""The NCR number an accept carries after a reject without one (C2). An empty one counts as not given."""
|
||||
raw = clean_text(extra.pop("ncr", None), 100)
|
||||
if raw:
|
||||
extra["ncr"] = ncrs.validate_ncr(raw)
|
||||
|
||||
|
||||
@router.post("/{packet_id}/reopen", dependencies=WRITE)
|
||||
def reopen(
|
||||
packet_id: int,
|
||||
@@ -958,9 +964,8 @@ def set_vclosed(
|
||||
"SELECT DISTINCT wo_base FROM packet_work_orders WHERE packet_id = %s ORDER BY 1", (packet_id,)
|
||||
)
|
||||
]
|
||||
still_open = packets.packet_open_wos(d.conn, w.packet)
|
||||
suffix = "VISUAL still shows open: " + ", ".join(still_open) if still_open else None
|
||||
w.note = " · ".join(x for x in (", ".join(wos), suffix) if x) or None
|
||||
# Only a hand packet gets here (C5): the feed closes a VISUAL packet, so there is no feed state to compare.
|
||||
w.note = ", ".join(wos) or None
|
||||
else:
|
||||
d.conn.execute("UPDATE packets SET vclosed_at = NULL, vclosed_by = NULL WHERE id = %s", (packet_id,))
|
||||
w.changed("vclosed")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Project Management: the read-only reports, the CSV exports and the production status PDF.
|
||||
|
||||
Any signed-in session reads (every role may open every packet, PM included). Nothing here writes a packet; an export
|
||||
PS-ProjectMgmt and Admin only (change C10, Gitea issue 33); every other role gets 403 on every endpoint here. No other
|
||||
screen calls /api/pm: the find bar and the boards read /api/packets. Nothing here writes a packet; an export
|
||||
writes one print_log row and one audit row (`report exported`). Every endpoint takes the find bar's scope: the
|
||||
project and unit pick, dept, status, station_id, welder_id, flags, flag, q, and the history period (30, 90, 365 or
|
||||
all days; default 90). Mine is ignored here. The reports read PostgreSQL only.
|
||||
@@ -21,7 +22,15 @@ from .deps import Db, Principal, current_principal, get_db, settings_of
|
||||
from .errors import ApiError
|
||||
from .packets import FIND
|
||||
|
||||
router = APIRouter(prefix="/api/pm", tags=["pm"])
|
||||
|
||||
def pm_only(p: Principal = Depends(current_principal)) -> Principal: # noqa: B008
|
||||
"""PS-ProjectMgmt (role `pm`) or Admin. Others get 403."""
|
||||
if not (p.is_admin or "pm" in p.roles):
|
||||
raise ApiError(403, "forbidden", "Project Management only.")
|
||||
return p
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/pm", tags=["pm"], dependencies=[Depends(pm_only)])
|
||||
|
||||
PERIOD = Query(pm.DEFAULT_PERIOD, pattern="^(30|90|365|all)$")
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""The shop screens: the supervisor board, the stations a person may work at, and the stall board.
|
||||
"""The shop screens: the supervisor board, the stations a person may work at, the stall board of one station, and
|
||||
the Stall screen's own list (/api/stall/mine: the packets assigned to me, across stations).
|
||||
|
||||
Any signed-in session reads them; the board answers 403 for someone who leads no department. They read
|
||||
PostgreSQL only.
|
||||
@@ -16,6 +17,7 @@ from .deps import Db, Principal, current_principal, get_db
|
||||
from .packets import FIND, site_today
|
||||
|
||||
router = APIRouter(prefix="/api/shop", tags=["shop"])
|
||||
stall_router = APIRouter(prefix="/api/stall", tags=["shop"])
|
||||
|
||||
|
||||
@router.get("/board")
|
||||
@@ -65,3 +67,19 @@ def stall(
|
||||
raw = {**find, "station_id": None, "mine_station_id": find.get("mine_station_id") or station_id}
|
||||
filters = search.parse_filters(raw, pp, screen="stall", today=site_today(request))
|
||||
return shop.stall(conn, pp, station_id, filters)
|
||||
|
||||
|
||||
@stall_router.get("/mine")
|
||||
def stall_mine(
|
||||
request: Request,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
find: dict[str, Any] = FIND,
|
||||
) -> dict[str, Any]:
|
||||
"""The Stall screen (change C11): the packets assigned to the signed-in person, each at the station the
|
||||
supervisor picked, across stations. No station in the request; the session decides. The find bar values narrow
|
||||
the cards (Mine is these cards already)."""
|
||||
conn = d.conn
|
||||
pp = packets.policy_principal_for(conn, p)
|
||||
filters = search.parse_filters(find, pp, screen="stall", today=site_today(request))
|
||||
return shop.stall_mine(conn, pp, filters)
|
||||
|
||||
Reference in New Issue
Block a user