Phase 4: Warehouse screen, pull table, shorts, MTRs and the upload pipeline
The Warehouse home with the numbers, the Open shorts table, the find bar and the five-column task board (waiting, being pulled, on hold, pulled, released in the last 3 days) with buttons, hold reasons and drag. Every role sees the warehouse stage on a packet as the WH pill and the line under it. The pull table on the Materials tab: Pulled with auto-start, issued, location, heat with Enter to the next line, shorts with receipts, N/A, MTR per line with a viewer. Send back to Clerical with a reason. Release to supervisor stays blocked until every line is pulled or short and every pulled line has a heat. Server: the heats_recorded precondition on WH_RELEASE, receive by custody, server-validated hold and short reasons, the old packet history texts, GET /api/warehouse/board, cards with the warehouse stage and pull counts. Uploads typed by content per kind (drawing, mtr, document, bom with XLSX and CSV), limits per kind, optional ClamAV behind the clamav compose profile, a storage quota alert on the new notification_events table (migration 0005) shown on the Admin Storage tab. Tests: every warehouse action as every role, the release gate, the short-receive exception, the heat pattern, upload sniffing and limits, the ClamAV path with a fake scanner, the quota alert; frontend board and pull table tests; Playwright scenarios for the board and the pull flow. Verified on docker-1 in the CI stack: 3557 server tests, 106 frontend tests, 17 Playwright scenarios. Docs updated; D5 closed. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
"""Admin: users and their AD view (read-only), PIN clearing, devices, the group map, AD refresh, attempts.
|
||||
"""Admin: users and their AD view (read-only), PIN clearing, devices, the group map, AD refresh, attempts, storage
|
||||
and alerts.
|
||||
|
||||
Every route needs the admin role and a password session. Roles cannot be changed here: they come from AD.
|
||||
"""
|
||||
@@ -21,8 +22,9 @@ from ..auth import groups as groupmap
|
||||
from ..auth import pin as pins
|
||||
from ..auth.ldap import LdapUnavailable
|
||||
from ..auth.signin import ldap_down
|
||||
from ..domain import assets, scan
|
||||
from .account import active_sessions
|
||||
from .deps import Db, Principal, admin_principal, directory_of, get_db
|
||||
from .deps import Db, Principal, admin_principal, directory_of, get_db, settings_of
|
||||
from .errors import ApiError
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
@@ -307,3 +309,102 @@ def signin_attempts(
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- storage and alerts
|
||||
|
||||
PING_MAX_SECONDS = 5.0
|
||||
ALERT_SELECT = (
|
||||
"SELECT n.*, u.display_name AS acknowledged_name FROM notification_events n"
|
||||
" LEFT JOIN users u ON u.id = n.acknowledged_by"
|
||||
)
|
||||
|
||||
|
||||
def _alert_row(r: dict[str, Any]) -> dict[str, Any]:
|
||||
by = r.get("acknowledged_by")
|
||||
return {
|
||||
"id": r["id"],
|
||||
"kind": r["kind"],
|
||||
"severity": r["severity"],
|
||||
"subject": r["subject"],
|
||||
"detail": r["detail"] or {},
|
||||
"created_at": r["created_at"],
|
||||
"resolved_at": r["resolved_at"],
|
||||
"acknowledged_at": r["acknowledged_at"],
|
||||
"acknowledged_by": {"id": str(by), "display_name": r.get("acknowledged_name")} if by else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/storage")
|
||||
def storage(
|
||||
request: Request,
|
||||
p: Principal = Depends(admin_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
s = settings_of(request)
|
||||
usage = assets.usage_for(d.conn, s)
|
||||
with d.conn.cursor(row_factory=dict_row) as cur:
|
||||
r = cur.execute(
|
||||
ALERT_SELECT + " WHERE n.kind = %s AND coalesce(n.subject, '') = %s AND n.resolved_at IS NULL"
|
||||
" ORDER BY n.id LIMIT 1",
|
||||
(assets.QUOTA_KIND, assets.QUOTA_SUBJECT),
|
||||
).fetchone()
|
||||
scanner = scan.scanner_for(s)
|
||||
clamav: dict[str, Any] = {"configured": scanner is not None, "host": None, "port": None, "reachable": None}
|
||||
if scanner is not None:
|
||||
clamav.update(
|
||||
host=scanner.host,
|
||||
port=scanner.port,
|
||||
reachable=scanner.ping(timeout=min(scanner.timeout, PING_MAX_SECONDS)),
|
||||
)
|
||||
return {
|
||||
"usage": usage,
|
||||
"quota_warn_pct": int(s.assets_quota_warn_pct),
|
||||
"quota_bytes": int(s.assets_quota_bytes),
|
||||
"alert": _alert_row(dict(r)) if r else None,
|
||||
"clamav": clamav,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/alerts")
|
||||
def alerts(
|
||||
p: Principal = Depends(admin_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
with d.conn.cursor(row_factory=dict_row) as cur:
|
||||
rows = cur.execute(
|
||||
ALERT_SELECT + " WHERE n.resolved_at IS NULL ORDER BY n.created_at DESC, n.id DESC"
|
||||
).fetchall()
|
||||
return {"alerts": [_alert_row(dict(r)) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/alerts/{alert_id}/ack")
|
||||
def ack_alert(
|
||||
alert_id: int,
|
||||
p: Principal = Depends(admin_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
conn = d.conn
|
||||
with conn.transaction():
|
||||
done = conn.execute(
|
||||
"UPDATE notification_events SET acknowledged_at = now(), acknowledged_by = %s"
|
||||
" WHERE id = %s AND acknowledged_at IS NULL RETURNING kind, subject",
|
||||
(p.user_id, alert_id),
|
||||
).fetchone()
|
||||
if done is not None:
|
||||
audit.append(
|
||||
conn,
|
||||
action="alert.acknowledged",
|
||||
user_id=p.user_id,
|
||||
session_id=p.session_id,
|
||||
role_snapshot=p.role_snapshot,
|
||||
subject_type="notification",
|
||||
subject_id=str(alert_id),
|
||||
detail={"kind": done[0], "subject": done[1]},
|
||||
ip=p.ip,
|
||||
)
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
r = cur.execute(ALERT_SELECT + " WHERE n.id = %s", (alert_id,)).fetchone()
|
||||
if r is None:
|
||||
raise ApiError(404, "not_found", "No such alert.")
|
||||
return _alert_row(dict(r))
|
||||
|
||||
@@ -10,7 +10,6 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||
from fastapi.responses import FileResponse
|
||||
from psycopg.types.json import Jsonb
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from starlette.datastructures import UploadFile
|
||||
|
||||
@@ -32,9 +31,12 @@ async def upload(
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
s = settings_of(request)
|
||||
# The purpose field is inside the body, so the pre-check uses the largest kind limit. The store applies the
|
||||
# kind's own limit while it streams.
|
||||
pre = assets.max_upload_limit(s)
|
||||
length = request.headers.get("content-length")
|
||||
if length is not None and length.isdigit() and int(length) > s.upload_max_bytes + FORM_SLACK:
|
||||
raise ApiError(413, "too_large", f"The file is over the {s.upload_max_bytes // (1024 * 1024)} MB limit.")
|
||||
if length is not None and length.isdigit() and int(length) > pre + FORM_SLACK:
|
||||
raise assets.too_large(pre)
|
||||
form = await request.form(max_files=1, max_fields=8)
|
||||
try:
|
||||
file = form.get("file")
|
||||
@@ -51,8 +53,10 @@ async def upload(
|
||||
)
|
||||
if exists is None:
|
||||
raise ApiError(404, "not_found", "No such packet.")
|
||||
purpose = form.get("purpose")
|
||||
ref = {"purpose": str(purpose)[:40]} if isinstance(purpose, str) and purpose.strip() else None
|
||||
raw_purpose = form.get("purpose")
|
||||
if raw_purpose is not None and not isinstance(raw_purpose, str):
|
||||
raise ApiError(422, "invalid_request", "purpose must be text.", extra={"field": "purpose"})
|
||||
kind = assets.kind_of(raw_purpose)
|
||||
upload_file = file
|
||||
return await run_in_threadpool(
|
||||
lambda: assets.store_upload(
|
||||
@@ -62,8 +66,8 @@ async def upload(
|
||||
original_name=upload_file.filename,
|
||||
user=p.user_id,
|
||||
packet_id=packet_id,
|
||||
source_ref=None if ref is None else Jsonb(ref),
|
||||
actor=p,
|
||||
purpose=kind.name,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
@@ -96,7 +100,7 @@ def blob(
|
||||
path = assets.open_blob(settings_of(request), row)
|
||||
etag = '"' + bytes(row["sha256"]).hex() + '"'
|
||||
headers = {
|
||||
"Content-Disposition": f'{"attachment" if download else "inline"}; filename="{assets.download_name(row)}"',
|
||||
"Content-Disposition": assets.disposition(row, bool(download)),
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Cache-Control": "private, max-age=3600",
|
||||
"ETag": etag,
|
||||
|
||||
@@ -30,8 +30,10 @@ from ..domain.pipeline import (
|
||||
next_counter,
|
||||
packet_write,
|
||||
parse_if_match,
|
||||
qty_text,
|
||||
require_if_match,
|
||||
)
|
||||
from ..policy.actions import STATUS_LABELS
|
||||
from ..text import LIMITS, RX_HEAT, RX_STAMP, clean_opt, clean_text
|
||||
from .deps import Db, Principal, current_principal, get_db
|
||||
from .errors import ApiError
|
||||
@@ -45,7 +47,7 @@ TEXT_SHORT = 40 # weld type, size, process, wo_base
|
||||
FILLER_MAX = 120
|
||||
QC_FIELD_MAX = 120
|
||||
|
||||
# The old warehouse hold reasons. Any other text is taken as given; "Other" needs a note.
|
||||
# The warehouse hold reasons, a closed set (the old build took any text). "Other" needs a note.
|
||||
WH_HOLD_REASONS = (
|
||||
"Waiting on material (not in stock)",
|
||||
"Waiting on purchasing / PO",
|
||||
@@ -53,6 +55,19 @@ WH_HOLD_REASONS = (
|
||||
"Drawing / BOM problem",
|
||||
"Other",
|
||||
)
|
||||
# The short reasons, a closed set (the old build took any text).
|
||||
SHORT_REASONS = (
|
||||
"Not in stock",
|
||||
"On order",
|
||||
"Back-ordered",
|
||||
"Damaged / rejected",
|
||||
"Wrong part received",
|
||||
"Needs cutting / prep",
|
||||
"Other",
|
||||
)
|
||||
WH_NOTE_MAX = 200
|
||||
# The file types an MTR may be.
|
||||
MTR_MIMES = frozenset({"application/pdf", "image/png", "image/jpeg"})
|
||||
# The old warehouse board: which stage may follow which.
|
||||
WH_NEXT: dict[str, frozenset[str]] = {
|
||||
"incoming": frozenset({"pulling", "hold"}),
|
||||
@@ -65,6 +80,8 @@ WH_LABELS = {
|
||||
"hold": "Put on hold",
|
||||
"pulled": "Marked pulled, ready for production",
|
||||
}
|
||||
# Labels for a move into pulling from a stage other than incoming.
|
||||
WH_PULLING_FROM = {"hold": "Pulling resumed", "pulled": "Back to pulling"}
|
||||
|
||||
MSG_NO_STAGE = "This packet has no open stage."
|
||||
MSG_NO_INSPECTION = "This packet has no open Q.C. inspection."
|
||||
@@ -260,12 +277,20 @@ def _read(d: Db, pp: policy.Principal, packet_id: int) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _heat(value: Any) -> str | None:
|
||||
"""A cleaned heat number, None when empty, 422 invalid_heat when it does not fit RX_HEAT."""
|
||||
"""A cleaned heat number, None when empty, 422 invalid_heat when it does not fit RX_HEAT.
|
||||
|
||||
Trailing white space is trimmed (a scanner may add it). A leading space is refused, as the pattern says.
|
||||
"""
|
||||
text = clean_text(value, 200)
|
||||
if not text:
|
||||
return None
|
||||
if len(text) > LIMITS["heat"] or not RX_HEAT.match(text):
|
||||
raise ApiError(422, "invalid_heat", "Heat numbers are letters, digits, spaces and . / # _ - only.")
|
||||
lead = isinstance(value, str) and value[:1].isspace()
|
||||
if lead or len(text) > LIMITS["heat"] or not RX_HEAT.match(text):
|
||||
raise ApiError(
|
||||
422,
|
||||
"invalid_heat",
|
||||
f'Heat number "{text}" isn\'t valid: letters, numbers, space . / # _ - only, up to 40 characters.',
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
@@ -323,6 +348,16 @@ def _num(value: Any) -> Decimal | None:
|
||||
return None if value is None else Decimal(str(value))
|
||||
|
||||
|
||||
def _status_tail(conn: Any, row: dict[str, Any]) -> str:
|
||||
"""The end of the receive note: " · packet ready to assign", plus " in {station}" when at a station."""
|
||||
out = f" · packet {STATUS_LABELS.get(str(row['status']), str(row['status'])).lower()}"
|
||||
if row["status"] in policy.ACTIVE and row["station_id"] is not None:
|
||||
st = conn.execute("SELECT name FROM stations WHERE id = %s", (row["station_id"],)).fetchone()
|
||||
if st and st[0]:
|
||||
out += f" in {st[0]}"
|
||||
return out
|
||||
|
||||
|
||||
def _diff(row: dict[str, Any], values: dict[str, Any]) -> dict[str, Any]:
|
||||
"""The values that differ from the row."""
|
||||
out: dict[str, Any] = {}
|
||||
@@ -510,10 +545,12 @@ def patch_bom_line(
|
||||
check_row_version(line, if_match)
|
||||
if _open_short(conn, line["id"]) is not None and given & {"heat", "issued", "loc"}:
|
||||
raise ApiError(409, "short_open", MSG_SHORT_OPEN)
|
||||
if values.get("mtr_asset_id") is not None and (
|
||||
conn.execute("SELECT 1 FROM assets WHERE id = %s", (values["mtr_asset_id"],)).fetchone() is None
|
||||
):
|
||||
raise ApiError(422, "invalid_asset", "That MTR file is not on the server. Upload it first.")
|
||||
if values.get("mtr_asset_id") is not None:
|
||||
asset = conn.execute("SELECT mime FROM assets WHERE id = %s", (values["mtr_asset_id"],)).fetchone()
|
||||
if asset is None:
|
||||
raise ApiError(422, "invalid_asset", "That MTR file is not on the server. Upload it first.")
|
||||
if asset[0] not in MTR_MIMES:
|
||||
raise ApiError(422, "invalid_asset", "An MTR is a PDF, PNG or JPG file.")
|
||||
newly_pulled = False
|
||||
if "pulled" in given:
|
||||
if body.pulled:
|
||||
@@ -534,24 +571,43 @@ def patch_bom_line(
|
||||
_apply_line(w, line, changes)
|
||||
w.changed(*[f"bom.{k}" for k in changes if k not in ("pulled_at", "pulled_by")])
|
||||
w.detail["item"] = item
|
||||
if "pulled" in changes:
|
||||
w.log(f"Item {item} pulled" if changes["pulled"] else f"Item {item} no longer pulled")
|
||||
elif "heat" in changes and len(changes) == 1:
|
||||
w.log(f"Heat recorded on item {item}" if changes["heat"] else f"Heat cleared on item {item}")
|
||||
w.note = changes["heat"]
|
||||
elif "na" in changes:
|
||||
w.log(f"Item {item} marked N/A (not traceable)" if changes["na"] else f"Item {item} marked traceable")
|
||||
elif "issued" in changes and len(changes) == 1:
|
||||
w.log(f"Issued qty changed on item {item}", f"{changes['issued']} of {line['qty']}")
|
||||
elif "mtr_asset_id" in changes and len(changes) == 1:
|
||||
w.log(f"MTR attached to item {item}" if changes["mtr_asset_id"] else f"MTR removed from item {item}")
|
||||
else:
|
||||
w.log(f"Item {item} changed")
|
||||
_log_line(w, item, line, changes, given)
|
||||
if newly_pulled:
|
||||
_auto_pull(w)
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
def _log_line(w: Write, item: int, line: dict[str, Any], changes: dict[str, Any], given: set[str]) -> None:
|
||||
"""The packet_log text of a line edit. Fields the server set along the way (issued on a Pulled tick, the heat
|
||||
cleared by N/A) do not count as a second field."""
|
||||
kinds = {k for k in changes if k not in ("pulled_at", "pulled_by")}
|
||||
if "pulled" in kinds and "issued" not in given:
|
||||
kinds.discard("issued")
|
||||
if "na" in kinds and "heat" not in given:
|
||||
kinds.discard("heat")
|
||||
if len(kinds) != 1:
|
||||
w.log(f"Line changed · item {item}")
|
||||
return
|
||||
kind = next(iter(kinds))
|
||||
if kind == "pulled":
|
||||
w.log(f"Pulled · item {item}" if changes["pulled"] else f"Pulled undone · item {item}")
|
||||
elif kind == "heat":
|
||||
if changes["heat"]:
|
||||
w.log(f"Heat recorded · item {item}", changes["heat"])
|
||||
else:
|
||||
w.log(f"Heat cleared · item {item}")
|
||||
elif kind == "na":
|
||||
w.log(f"Marked N/A · item {item}" if changes["na"] else f"Traceable again · item {item}")
|
||||
elif kind == "issued":
|
||||
w.log(f"Issued qty changed · item {item}", f"{qty_text(changes['issued'])} of {qty_text(line['qty'])}")
|
||||
elif kind == "loc":
|
||||
w.log(f"Location set · item {item}", changes["loc"] or None)
|
||||
elif kind == "mtr_asset_id":
|
||||
w.log(f"MTR attached · item {item}" if changes["mtr_asset_id"] else f"MTR removed · item {item}")
|
||||
else:
|
||||
w.log(f"Line changed · item {item}")
|
||||
|
||||
|
||||
@router.post("/{packet_id}/bom/{item}/short", dependencies=WRITE)
|
||||
def save_short(
|
||||
packet_id: int,
|
||||
@@ -563,6 +619,8 @@ def save_short(
|
||||
) -> dict[str, Any]:
|
||||
"""Open a short on a line, or update the open one. expect_open says which the caller saw."""
|
||||
reason = clean_opt(body.reason, LIMITS["reason"])
|
||||
if reason not in SHORT_REASONS:
|
||||
raise ApiError(400, "invalid_reason", "Pick one of the short reasons.")
|
||||
ref = clean_opt(body.ref, LIMITS["po"])
|
||||
note = clean_opt(body.note, LIMITS["note"])
|
||||
eta = clean_opt(body.eta, 40)
|
||||
@@ -595,7 +653,7 @@ def save_short(
|
||||
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)",
|
||||
(packet_id, line["id"], body.qty, body.qty, reason, ref, eta, note, w.user_id),
|
||||
)
|
||||
label = f"Short recorded on item {item}"
|
||||
label = f"Short recorded · item {item}"
|
||||
else:
|
||||
received = (_num(short["qty"]) or Decimal(0)) - (_num(short["qty_open"]) or Decimal(0))
|
||||
update_row(
|
||||
@@ -611,7 +669,7 @@ def save_short(
|
||||
"note": note,
|
||||
},
|
||||
)
|
||||
label = f"Short updated on item {item}"
|
||||
label = f"Short updated · item {item}"
|
||||
values: dict[str, Any] = {"issued": need - body.qty}
|
||||
_set_pulled(w, line, values)
|
||||
if heat is not None and not line["na"]:
|
||||
@@ -621,8 +679,8 @@ def save_short(
|
||||
if changes:
|
||||
_apply_line(w, line, changes)
|
||||
w.changed("bom.short")
|
||||
w.detail["item"] = item
|
||||
parts = [f"{body.qty} of {need} short"]
|
||||
w.detail.update({"item": item, "qty": body.qty})
|
||||
parts = [f"{body.qty} of {qty_text(need)} short"]
|
||||
parts += [x for x in (reason, ref, f"ETA {eta}" if eta else None) if x]
|
||||
w.log(label, " · ".join(parts))
|
||||
if newly_pulled:
|
||||
@@ -660,8 +718,8 @@ def clear_short(
|
||||
if changes:
|
||||
_apply_line(w, line, changes)
|
||||
w.changed("bom.short")
|
||||
w.detail["item"] = item
|
||||
w.log(f"Short cleared on item {item}")
|
||||
w.detail.update({"item": item, "qty": float(short["qty_open"])})
|
||||
w.log(f"Short cleared · item {item}")
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
@@ -674,14 +732,17 @@ def receive_short(
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""The warehouse receives short parts, in any status but filed. The short closes when nothing is open."""
|
||||
"""Receive short parts. The short closes when nothing is open.
|
||||
|
||||
Receive by custody: a warehouse member receives in any status but filed (WH_RECEIVE_SHORT); anyone else
|
||||
receives as the holder of the packet (EDIT_BOM), and the policy refuses everyone who is neither.
|
||||
"""
|
||||
heat = _heat(body.heat)
|
||||
note = clean_opt(body.note, LIMITS["note"])
|
||||
pp = _pp(d, p)
|
||||
conn = d.conn
|
||||
with packet_write(
|
||||
d, p, packet_id, A.WH_RECEIVE_SHORT, request=request, it_reason=body.it_reason, policy_principal=pp
|
||||
) as w:
|
||||
action = A.WH_RECEIVE_SHORT if "WH" in pp.support else A.EDIT_BOM
|
||||
with packet_write(d, p, packet_id, action, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
|
||||
line = _bom_line(conn, packet_id, item)
|
||||
short = _open_short(conn, line["id"])
|
||||
if short is None:
|
||||
@@ -710,10 +771,11 @@ def receive_short(
|
||||
_apply_line(w, line, values)
|
||||
w.changed("bom.short")
|
||||
w.detail.update({"item": item, "qty": body.qty})
|
||||
tail = f"{still} still short" if still > 0 else "short closed"
|
||||
tail = f"{qty_text(still)} still short" if still > 0 else "short closed"
|
||||
w.log(
|
||||
f"Short received on item {item}",
|
||||
" · ".join(x for x in (f"{body.qty} pc", heat and f"heat {heat}", tail) if x),
|
||||
f"Short received · item {item}",
|
||||
" · ".join(x for x in (f"{body.qty} pc", heat and f"heat {heat}", tail) if x)
|
||||
+ _status_tail(conn, w.packet),
|
||||
)
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
@@ -1141,10 +1203,10 @@ def set_wh_stage(
|
||||
) -> dict[str, Any]:
|
||||
"""Move the warehouse board: incoming, pulling, hold, pulled. expect_stage is what the caller saw."""
|
||||
reason = clean_opt(body.reason, LIMITS["reason"])
|
||||
note = clean_opt(body.note, LIMITS["note"])
|
||||
note = clean_opt(body.note, WH_NOTE_MAX)
|
||||
if body.stage == "hold":
|
||||
if reason is None:
|
||||
raise ApiError(400, "reason_required", "Say why it is on hold.")
|
||||
if reason not in WH_HOLD_REASONS:
|
||||
raise ApiError(400, "invalid_reason", "Pick one of the hold reasons.")
|
||||
if reason == "Other" and note is None:
|
||||
raise ApiError(400, "reason_required", "Say what it's waiting on.")
|
||||
pp = _pp(d, p)
|
||||
@@ -1173,7 +1235,10 @@ def set_wh_stage(
|
||||
note=note,
|
||||
auto=False,
|
||||
)
|
||||
label = "Pulling resumed" if stage == "hold" and body.stage == "pulling" else WH_LABELS.get(body.stage, "")
|
||||
if body.stage == "pulling" and stage in WH_PULLING_FROM:
|
||||
label = WH_PULLING_FROM[stage]
|
||||
else:
|
||||
label = WH_LABELS.get(body.stage, "")
|
||||
w.changed("wh")
|
||||
w.detail.update({"wh_from": stage, "wh_to": body.stage})
|
||||
w.log(
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""The warehouse board: KPIs, the four stage columns, the released column and the open shorts.
|
||||
|
||||
Any signed-in session reads it. It reads PostgreSQL only. The cards are the list_cards shape, so the pill, the wh
|
||||
line and the pull progress read the same on every screen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
|
||||
from ..domain import packets
|
||||
from ..text import clean_text
|
||||
from .deps import Db, Principal, current_principal, get_db, settings_of
|
||||
|
||||
router = APIRouter(prefix="/api/warehouse", tags=["warehouse"])
|
||||
|
||||
STAGES = ("incoming", "pulling", "hold", "pulled")
|
||||
RELEASED_DAYS = 3
|
||||
BOARD_LIMIT = 5000
|
||||
|
||||
# Packets with a wh_release transition in the window; a packet back in the warehouse is on a stage column instead.
|
||||
RELEASED_SQL = """
|
||||
SELECT t.packet_id, max(t.at) AS released_at
|
||||
FROM packet_transitions t JOIN packets p ON p.id = t.packet_id JOIN units u ON u.id = p.unit_id
|
||||
WHERE t.action = 'wh_release' AND t.at > now() - %s * interval '1 day' AND p.status <> 'warehouse'
|
||||
AND p.removed_at IS NULL {scope}
|
||||
GROUP BY t.packet_id
|
||||
"""
|
||||
|
||||
SHORTS_SQL = """
|
||||
SELECT s.id AS short_id, s.packet_id, s.bom_line_id, s.qty_open, s.reason, s.ref, s.eta, s.note, s.opened_at,
|
||||
s.row_version, b.item, b.description, b.part_id, b.material, b.length, b.qty, b.na
|
||||
FROM bom_shorts s JOIN bom_lines b ON b.id = s.bom_line_id
|
||||
WHERE s.closed_at IS NULL AND s.packet_id = ANY(%s)
|
||||
ORDER BY s.eta NULLS LAST, s.opened_at, s.id
|
||||
"""
|
||||
|
||||
|
||||
# The KPIs. {scope} is the fixed project and unit terms from _scope, never request text.
|
||||
KPI_NAMES = {"incoming": "waiting", "pulling": "pulling", "hold": "hold", "pulled": "pulled"}
|
||||
KPI_STAGES_SQL = """
|
||||
SELECT coalesce(w.stage::text, 'incoming'), count(*)
|
||||
FROM packets p JOIN units u ON u.id = p.unit_id LEFT JOIN packet_wh w ON w.packet_id = p.id AND w.closed_at IS NULL
|
||||
WHERE p.status = 'warehouse' AND p.removed_at IS NULL {scope}
|
||||
GROUP BY 1
|
||||
"""
|
||||
KPI_SHORTS_SQL = """
|
||||
SELECT count(*) FROM bom_shorts s JOIN packets p ON p.id = s.packet_id JOIN units u ON u.id = p.unit_id
|
||||
WHERE s.closed_at IS NULL AND p.removed_at IS NULL {scope}
|
||||
"""
|
||||
KPI_RELEASED_SQL = """
|
||||
SELECT count(DISTINCT t.packet_id)
|
||||
FROM packet_transitions t JOIN packets p ON p.id = t.packet_id JOIN units u ON u.id = p.unit_id
|
||||
WHERE t.action = 'wh_release' AND t.at > now() - interval '24 hours' AND p.status <> 'warehouse'
|
||||
AND p.removed_at IS NULL {scope}
|
||||
"""
|
||||
|
||||
|
||||
def _tz(request: Request) -> Any:
|
||||
try:
|
||||
return ZoneInfo(settings_of(request).site_tz)
|
||||
except (ZoneInfoNotFoundError, ValueError):
|
||||
return UTC
|
||||
|
||||
|
||||
def _scope(filters: dict[str, Any]) -> tuple[str, list[Any]]:
|
||||
"""The project and unit terms (the KPIs follow these only), over packets p and units u."""
|
||||
terms: list[str] = []
|
||||
params: list[Any] = []
|
||||
if filters.get("unit_id") is not None:
|
||||
terms.append("p.unit_id = %s")
|
||||
params.append(int(filters["unit_id"]))
|
||||
if filters.get("project_id") is not None:
|
||||
terms.append("u.project_id = %s")
|
||||
params.append(int(filters["project_id"]))
|
||||
return "".join(f" AND {t}" for t in terms), params
|
||||
|
||||
|
||||
def _order_key(card: dict[str, Any], tz: Any) -> tuple[str, str, float]:
|
||||
"""The old whOrder: the day it was sent, then VISUAL's release date (none last), then the exact time."""
|
||||
sent = card.get("wh_sent_at")
|
||||
day = sent.astimezone(tz).date().isoformat() if isinstance(sent, datetime) else "9999"
|
||||
rel = card.get("visual_rel")
|
||||
rel_text = rel.isoformat() if isinstance(rel, date) else "9999"
|
||||
exact = sent.timestamp() if isinstance(sent, datetime) else float("inf")
|
||||
return day, rel_text, exact
|
||||
|
||||
|
||||
def _kpis(conn: Any, filters: dict[str, Any]) -> dict[str, int]:
|
||||
scope, params = _scope(filters)
|
||||
out = {"waiting": 0, "pulling": 0, "hold": 0, "pulled": 0, "open_shorts": 0, "released_24h": 0}
|
||||
for stage, n in conn.execute(KPI_STAGES_SQL.format(scope=scope), params):
|
||||
if stage in KPI_NAMES:
|
||||
out[KPI_NAMES[stage]] = int(n)
|
||||
out["open_shorts"] = int(conn.execute(KPI_SHORTS_SQL.format(scope=scope), params).fetchone()[0])
|
||||
out["released_24h"] = int(conn.execute(KPI_RELEASED_SQL.format(scope=scope), params).fetchone()[0])
|
||||
return out
|
||||
|
||||
|
||||
def _short_row(s: dict[str, Any], card: dict[str, Any], today: date, now: datetime) -> dict[str, Any]:
|
||||
eta = s["eta"]
|
||||
return {
|
||||
"packet": {
|
||||
"id": card["id"],
|
||||
"unit_number": card["unit"]["number"],
|
||||
"op": card["op"],
|
||||
"drawing": card["drawing"],
|
||||
"status": card["status"],
|
||||
"wh_stage": card["wh_stage"],
|
||||
"station": card["station"],
|
||||
"holder": card["holder"],
|
||||
"dept": card["dept"],
|
||||
},
|
||||
"item": s["item"],
|
||||
"bom_line_id": s["bom_line_id"],
|
||||
"description": s["description"],
|
||||
"part_id": s["part_id"],
|
||||
"material": s["material"],
|
||||
"length": s["length"],
|
||||
"qty": s["qty"],
|
||||
"qty_open": s["qty_open"],
|
||||
"na": bool(s["na"]),
|
||||
"reason": s["reason"],
|
||||
"ref": s["ref"],
|
||||
"eta": eta,
|
||||
"note": s["note"],
|
||||
"opened_at": s["opened_at"],
|
||||
"age_days": max(0, (now - s["opened_at"]).days),
|
||||
"late": eta is not None and eta < today,
|
||||
"short_id": s["short_id"],
|
||||
"row_version": s["row_version"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/board")
|
||||
def board(
|
||||
request: Request,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
q: str = Query("", max_length=200),
|
||||
project_id: int | None = Query(None),
|
||||
unit_id: int | None = Query(None),
|
||||
dept: str = Query("", max_length=10),
|
||||
) -> dict[str, Any]:
|
||||
conn = d.conn
|
||||
pp = packets.policy_principal_for(conn, p)
|
||||
tz = _tz(request)
|
||||
filters: dict[str, Any] = {
|
||||
"q": clean_text(q, 200),
|
||||
"project_id": project_id,
|
||||
"unit_id": unit_id,
|
||||
"dept": dept.strip() or None,
|
||||
"limit": BOARD_LIMIT,
|
||||
}
|
||||
|
||||
columns: dict[str, list[dict[str, Any]]] = {st: [] for st in (*STAGES, "released")}
|
||||
for card in packets.list_cards(conn, pp, {**filters, "status": "warehouse"}):
|
||||
columns[card["wh_stage"] if card["wh_stage"] in STAGES else "incoming"].append(card)
|
||||
for st in STAGES:
|
||||
columns[st].sort(key=lambda c: _order_key(c, tz))
|
||||
|
||||
scope, params = _scope(filters)
|
||||
released = {int(r[0]): r[1] for r in conn.execute(RELEASED_SQL.format(scope=scope), [RELEASED_DAYS, *params])}
|
||||
if released:
|
||||
cards = packets.list_cards(conn, pp, {**filters, "ids": list(released)})
|
||||
for card in cards:
|
||||
card["released_at"] = released[int(card["id"])]
|
||||
cards.sort(key=lambda c: (c["released_at"], c["id"]), reverse=True)
|
||||
columns["released"] = cards
|
||||
|
||||
short_ids = [int(r[0]) for r in conn.execute("SELECT DISTINCT packet_id FROM bom_shorts WHERE closed_at IS NULL")]
|
||||
shorts: list[dict[str, Any]] = []
|
||||
if short_ids:
|
||||
by_id = {int(c["id"]): c for c in packets.list_cards(conn, pp, {**filters, "ids": short_ids})}
|
||||
if by_id:
|
||||
now = datetime.now(UTC)
|
||||
today = now.astimezone(tz).date()
|
||||
for sr in packets.rows(conn, SHORTS_SQL, (list(by_id),)):
|
||||
shorts.append(_short_row(sr, by_id[int(sr["packet_id"])], today, now))
|
||||
|
||||
return {"kpis": _kpis(conn, filters), "columns": columns, "shorts": shorts}
|
||||
@@ -23,7 +23,7 @@ from . import __version__
|
||||
from .settings import SecretFileError, Settings
|
||||
|
||||
PASS, WARN, FAIL, SKIP = "PASS", "WARN", "FAIL", "SKIP"
|
||||
SKIPPABLE = ("visual", "mfiles", "ldap", "smtp", "db", "volume")
|
||||
SKIPPABLE = ("visual", "mfiles", "ldap", "smtp", "db", "volume", "clamav")
|
||||
FREE_WARN_BYTES = 2 * 1024**3
|
||||
EXPECTED_PS_GROUPS = 50
|
||||
IN_CHAIN = "1.2.840.113556.1.4.1941"
|
||||
@@ -261,6 +261,28 @@ def check_volume(s: Settings) -> list[Check]:
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- clamav
|
||||
|
||||
|
||||
def check_clamav(s: Settings) -> list[Check]:
|
||||
from .domain import scan
|
||||
|
||||
scanner = scan.scanner_for(s)
|
||||
if scanner is None:
|
||||
return [Check("clamav", SKIP, "CLAMAV_HOST not set: uploads are not virus scanned")]
|
||||
where = f"{scanner.host}:{scanner.port}"
|
||||
if scanner.ping(timeout=min(scanner.timeout, 5.0)):
|
||||
return [Check("clamav", PASS, f"clamd at {where} answers PING")]
|
||||
return [
|
||||
Check(
|
||||
"clamav",
|
||||
FAIL,
|
||||
f"clamd at {where} does not answer; uploads are refused until it does",
|
||||
"start the stack with --profile clamav, or clear CLAMAV_HOST",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- ldap
|
||||
|
||||
|
||||
@@ -416,6 +438,7 @@ def run(
|
||||
("mfiles", check_mfiles),
|
||||
("db", check_db),
|
||||
("volume", check_volume),
|
||||
("clamav", check_clamav),
|
||||
("ldap", check_ldap),
|
||||
("smtp", lambda st: check_smtp(st, send_test)),
|
||||
]
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
"""Stored files. One row per file in `assets`; the bytes live at ASSETS_DIR/<id[:2]>/<id>.
|
||||
|
||||
An upload is streamed to a temp file in ASSETS_DIR in chunks (never held in memory whole), hashed as it goes,
|
||||
typed by its first bytes (not by its name or the browser's claim), fsynced and renamed into place. The same bytes
|
||||
uploaded twice (same sha256 and size) return the existing row.
|
||||
typed by its content (not by its name or the browser's claim), fsynced, scanned when ClamAV is configured and
|
||||
renamed into place. The same bytes uploaded twice (same sha256 and size) return the existing row.
|
||||
|
||||
Each upload has a kind (its purpose) that sets the types it may be and its size limit. See KINDS.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import codecs
|
||||
import contextlib
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import uuid
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, BinaryIO
|
||||
|
||||
@@ -24,30 +30,159 @@ from .. import audit
|
||||
from ..api.errors import ApiError
|
||||
from ..settings import Settings
|
||||
from ..text import clean_text
|
||||
from . import scan
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CHUNK = 1024 * 1024
|
||||
HEAD = 16
|
||||
MIB = 1024 * 1024
|
||||
PDF = "application/pdf"
|
||||
PNG = "image/png"
|
||||
JPEG = "image/jpeg"
|
||||
XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
CSV = "text/csv"
|
||||
ZIP_MAGIC = b"PK\x03\x04"
|
||||
MAGIC: tuple[tuple[bytes, str], ...] = (
|
||||
(b"%PDF-", PDF),
|
||||
(b"\x89PNG\r\n\x1a\n", "image/png"),
|
||||
(b"\xff\xd8\xff", "image/jpeg"),
|
||||
(b"\x89PNG\r\n\x1a\n", PNG),
|
||||
(b"\xff\xd8\xff", JPEG),
|
||||
(ZIP_MAGIC, XLSX),
|
||||
)
|
||||
EXT = {PDF: ".pdf", "image/png": ".png", "image/jpeg": ".jpg"}
|
||||
EXT = {PDF: ".pdf", PNG: ".png", JPEG: ".jpg", XLSX: ".xlsx", CSV: ".csv"}
|
||||
# Served as a download only, never shown in the browser.
|
||||
ATTACHMENT_ONLY = frozenset({XLSX, CSV})
|
||||
XLSX_PARTS = ("[Content_Types].xml", "xl/workbook.xml")
|
||||
CSV_SAMPLE = 64 * 1024
|
||||
_CSV_ALLOWED_C0 = frozenset("\t\r\n")
|
||||
NAME_MAX = 160
|
||||
_UNSAFE_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]')
|
||||
MSG_DOCUMENT_TYPE = "Only PDF, PNG and JPEG files can be stored."
|
||||
|
||||
|
||||
def sniff(head: bytes) -> str | None:
|
||||
"""The mime type from the first bytes, or None when it is not a type Packet Station stores."""
|
||||
@dataclass(frozen=True)
|
||||
class Kind:
|
||||
"""An upload kind: the types it may be (by content) and the setting that holds its size limit."""
|
||||
|
||||
name: str
|
||||
mimes: frozenset[str]
|
||||
limit_setting: str
|
||||
wrong_type: str
|
||||
|
||||
|
||||
KINDS: dict[str, Kind] = {
|
||||
"drawing": Kind("drawing", frozenset({PDF}), "upload_max_bytes", "A drawing is a PDF file."),
|
||||
"mtr": Kind("mtr", frozenset({PDF, PNG, JPEG}), "upload_max_bytes", "An MTR is a PDF, PNG or JPG file."),
|
||||
"document": Kind("document", frozenset({PDF, PNG, JPEG}), "upload_max_bytes", MSG_DOCUMENT_TYPE),
|
||||
"bom": Kind("bom", frozenset({XLSX, CSV}), "upload_max_bytes_bom", "A BOM is an Excel (.xlsx) or CSV file."),
|
||||
}
|
||||
DEFAULT_KIND = "document"
|
||||
# Older client names for a kind.
|
||||
KIND_ALIASES = {"doc": "document", "docs": "document"}
|
||||
|
||||
|
||||
def kind_of(purpose: Any) -> Kind:
|
||||
"""The upload kind for a `purpose` value. Missing or blank is `document`; anything unknown is 422."""
|
||||
if purpose is None:
|
||||
return KINDS[DEFAULT_KIND]
|
||||
name = str(purpose).strip().lower()
|
||||
if not name:
|
||||
return KINDS[DEFAULT_KIND]
|
||||
name = KIND_ALIASES.get(name, name)
|
||||
found = KINDS.get(name)
|
||||
if found is None:
|
||||
raise ApiError(
|
||||
422, "invalid_request", f"purpose must be one of {', '.join(KINDS)}.", extra={"field": "purpose"}
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
def kind_limit(settings: Settings, kind: Kind) -> int:
|
||||
return int(getattr(settings, kind.limit_setting))
|
||||
|
||||
|
||||
def max_upload_limit(settings: Settings) -> int:
|
||||
"""The largest limit of any kind (the multipart pre-check, before the purpose field is read)."""
|
||||
return max(kind_limit(settings, k) for k in KINDS.values())
|
||||
|
||||
|
||||
def limit_text(limit: int) -> str:
|
||||
if limit >= MIB:
|
||||
mb = limit / MIB
|
||||
return f"{mb:.0f} MB" if mb == int(mb) else f"{mb:.1f} MB"
|
||||
return f"{max(1, limit // 1024)} KB"
|
||||
|
||||
|
||||
def too_large(limit: int) -> ApiError:
|
||||
return ApiError(413, "too_large", f"The file is over the {limit_text(limit)} limit.", extra={"limit": limit})
|
||||
|
||||
|
||||
def _magic(head: bytes) -> str | None:
|
||||
for magic, mime in MAGIC:
|
||||
if head.startswith(magic):
|
||||
return mime
|
||||
return None
|
||||
|
||||
|
||||
def is_xlsx(source: Path | bytes) -> bool:
|
||||
"""A zip that lists the parts of an Excel workbook. A plain zip (or a .docx) is not one."""
|
||||
try:
|
||||
if isinstance(source, bytes | bytearray):
|
||||
import io
|
||||
|
||||
zf = zipfile.ZipFile(io.BytesIO(bytes(source)))
|
||||
else:
|
||||
zf = zipfile.ZipFile(source)
|
||||
with zf:
|
||||
names = set(zf.namelist())
|
||||
except (zipfile.BadZipFile, zipfile.LargeZipFile, OSError, ValueError, EOFError, KeyError):
|
||||
return False
|
||||
return all(part in names for part in XLSX_PARTS)
|
||||
|
||||
|
||||
def is_csv_text(sample: bytes, *, complete: bool) -> bool:
|
||||
"""UTF-8 text (a byte order mark is allowed) with no NUL and no control character other than tab, CR, LF.
|
||||
|
||||
`complete` is False when the sample is a cut from a longer file: a character split at the end is allowed.
|
||||
"""
|
||||
if not sample or b"\x00" in sample:
|
||||
return False
|
||||
if sample.startswith(codecs.BOM_UTF8):
|
||||
sample = sample[len(codecs.BOM_UTF8) :]
|
||||
try:
|
||||
text = codecs.getincrementaldecoder("utf-8")("strict").decode(sample, final=complete)
|
||||
except UnicodeDecodeError:
|
||||
return False
|
||||
return not any(ord(c) < 0x20 and c not in _CSV_ALLOWED_C0 for c in text)
|
||||
|
||||
|
||||
def _read_sample(path: Path) -> tuple[bytes, bool]:
|
||||
with Path(path).open("rb") as f:
|
||||
sample = f.read(CSV_SAMPLE + 1)
|
||||
return sample[:CSV_SAMPLE], len(sample) <= CSV_SAMPLE
|
||||
|
||||
|
||||
def sniff(data: bytes, path: Path | None = None, *, purpose: Any = None) -> str | None:
|
||||
"""The mime type from the content, or None when it is not a type Packet Station stores.
|
||||
|
||||
`data` is the first bytes (or all of them). PDF, PNG and JPEG go by their magic. A zip is XLSX only when it lists
|
||||
the workbook parts: the file at `path` is opened, or `data` when no path is given. CSV has no magic: it is
|
||||
considered only for purpose `bom`, from the first 64 KB of `path` (or of `data`).
|
||||
"""
|
||||
mime = _magic(data)
|
||||
if mime == XLSX:
|
||||
return XLSX if is_xlsx(path if path is not None else data) else None
|
||||
if mime is not None:
|
||||
return mime
|
||||
if purpose is None or kind_of(purpose).name != "bom":
|
||||
return None
|
||||
if path is not None:
|
||||
sample, complete = _read_sample(path)
|
||||
else:
|
||||
sample, complete = data[:CSV_SAMPLE], len(data) <= CSV_SAMPLE
|
||||
return CSV if is_csv_text(sample, complete=complete) else None
|
||||
|
||||
|
||||
def safe_name(name: Any) -> str | None:
|
||||
"""The original file name, cleaned: no path, no separators or reserved characters, at most 160 characters."""
|
||||
text = clean_text(name, 1000)
|
||||
@@ -176,6 +311,63 @@ def _insert(conn: Any, values: tuple[Any, ...]) -> dict[str, Any]:
|
||||
return dict(row)
|
||||
|
||||
|
||||
def _type_error(kind: Kind) -> ApiError:
|
||||
return ApiError(415, "unsupported_type", kind.wrong_type, extra={"allowed": sorted(EXT[m] for m in kind.mimes)})
|
||||
|
||||
|
||||
def _content_type(path: Path, head: bytes, kind: Kind) -> str | None:
|
||||
"""The mime of a complete file for this kind, or None when it is not one of the kind's types."""
|
||||
mime = sniff(head, path, purpose=kind.name if CSV in kind.mimes else None)
|
||||
return mime if mime in kind.mimes else None
|
||||
|
||||
|
||||
def _audit_rejected(
|
||||
conn: Any, err: ApiError, *, user: Any, actor: Any, packet_id: int | None, size: int, mime: str | None
|
||||
) -> None:
|
||||
"""The audit row for an upload the virus scan refused. A failure here never hides the refusal."""
|
||||
try:
|
||||
audit.append(
|
||||
conn,
|
||||
action="asset.rejected",
|
||||
user_id=user,
|
||||
session_id=getattr(actor, "session_id", None),
|
||||
role_snapshot=getattr(actor, "role_snapshot", None),
|
||||
subject_type="asset",
|
||||
packet_id=packet_id,
|
||||
detail={"reason": "infected", "signature": err.extra.get("signature"), "size": size, "mime": mime},
|
||||
ip=getattr(actor, "ip", None),
|
||||
)
|
||||
except Exception:
|
||||
log.exception("could not write the asset.rejected audit row")
|
||||
|
||||
|
||||
def _scan(
|
||||
conn: Any,
|
||||
settings: Settings,
|
||||
path: Path,
|
||||
*,
|
||||
user: Any,
|
||||
actor: Any,
|
||||
packet_id: int | None,
|
||||
size: int,
|
||||
mime: str | None,
|
||||
) -> None:
|
||||
try:
|
||||
scan.check(settings, path)
|
||||
except ApiError as e:
|
||||
if e.code == "infected":
|
||||
_audit_rejected(conn, e, user=user, actor=actor, packet_id=packet_id, size=size, mime=mime)
|
||||
raise
|
||||
|
||||
|
||||
def _with_purpose(source_ref: Any, kind: Kind) -> Any:
|
||||
if source_ref is None:
|
||||
return {"purpose": kind.name}
|
||||
if isinstance(source_ref, dict):
|
||||
return {"purpose": kind.name, **source_ref}
|
||||
return source_ref
|
||||
|
||||
|
||||
def store_upload(
|
||||
conn: Any,
|
||||
settings: Settings,
|
||||
@@ -187,14 +379,20 @@ def store_upload(
|
||||
source: str = "upload",
|
||||
source_ref: Any = None,
|
||||
actor: Any = None,
|
||||
purpose: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Store one upload and return its public row. Raises 413 too_large, 415 unsupported_type, 422 bad_pdf.
|
||||
"""Store one upload and return its public row.
|
||||
|
||||
`user` is the uploader's user id; `actor` (the session Principal) is used for the audit row when given.
|
||||
`purpose` picks the kind (see KINDS; missing is `document`: PDF, PNG or JPEG up to UPLOAD_MAX_BYTES). The type
|
||||
is decided by content. When ClamAV is configured the complete file is scanned before the dedupe lookup and the
|
||||
rename. Raises 422 invalid_request (unknown purpose), 413 too_large, 415 unsupported_type, 422 bad_pdf,
|
||||
422 infected and 503 scan_unavailable. `user` is the uploader's user id; `actor` (the session Principal) is used
|
||||
for the audit row when given. `source_ref` records the kind as `{"purpose": kind}`.
|
||||
"""
|
||||
kind = kind_of(purpose)
|
||||
root = Path(settings.assets_dir)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
limit = int(settings.upload_max_bytes)
|
||||
limit = kind_limit(settings, kind)
|
||||
fd, tmp_name = tempfile.mkstemp(prefix=".up-", dir=str(root))
|
||||
tmp = Path(tmp_name)
|
||||
keep = False
|
||||
@@ -203,7 +401,7 @@ def store_upload(
|
||||
digest = hashlib.sha256()
|
||||
size = 0
|
||||
head = b""
|
||||
mime: str | None = None
|
||||
checked = False
|
||||
with os.fdopen(fd, "wb") as out:
|
||||
while True:
|
||||
chunk = stream.read(CHUNK)
|
||||
@@ -211,21 +409,25 @@ def store_upload(
|
||||
break
|
||||
size += len(chunk)
|
||||
if size > limit:
|
||||
raise ApiError(413, "too_large", f"The file is over the {limit // (1024 * 1024)} MB limit.")
|
||||
if mime is None and len(head) < HEAD:
|
||||
raise too_large(limit)
|
||||
if not checked and len(head) < HEAD:
|
||||
head += chunk[: HEAD - len(head)]
|
||||
if len(head) >= HEAD:
|
||||
mime = sniff(head)
|
||||
if mime is None:
|
||||
raise ApiError(415, "unsupported_type", "Only PDF, PNG and JPEG files can be stored.")
|
||||
checked = True
|
||||
early = _magic(head)
|
||||
# Refuse a wrong type before the rest is read. CSV has no magic, so it waits for the end.
|
||||
if early not in kind.mimes and not (early is None and CSV in kind.mimes):
|
||||
raise _type_error(kind)
|
||||
digest.update(chunk)
|
||||
out.write(chunk)
|
||||
out.flush()
|
||||
os.fsync(out.fileno())
|
||||
if size == 0:
|
||||
raise _type_error(kind)
|
||||
mime = _content_type(tmp, head, kind)
|
||||
if mime is None:
|
||||
mime = sniff(head)
|
||||
if mime is None or size == 0:
|
||||
raise ApiError(415, "unsupported_type", "Only PDF, PNG and JPEG files can be stored.")
|
||||
raise _type_error(kind)
|
||||
_scan(conn, settings, tmp, user=user, actor=actor, packet_id=packet_id, size=size, mime=mime)
|
||||
sha = digest.digest()
|
||||
existing = _existing(conn, sha, size)
|
||||
if existing is not None:
|
||||
@@ -234,8 +436,9 @@ def store_upload(
|
||||
aid, key, placed = _place(settings, tmp)
|
||||
keep = True
|
||||
name = safe_name(original_name)
|
||||
ref = _with_purpose(source_ref, kind)
|
||||
with conn.transaction():
|
||||
row = _insert(conn, (aid, sha, size, mime, name, key, source, source_ref, pages, user, packet_id))
|
||||
row = _insert(conn, (aid, sha, size, mime, name, key, source, ref, pages, user, packet_id))
|
||||
audit.append(
|
||||
conn,
|
||||
action="asset.uploaded",
|
||||
@@ -245,10 +448,10 @@ def store_upload(
|
||||
subject_type="asset",
|
||||
subject_id=str(aid),
|
||||
packet_id=packet_id,
|
||||
detail={"mime": mime, "size": size, "pages": pages, "sha256": sha.hex()},
|
||||
detail={"mime": mime, "size": size, "pages": pages, "sha256": sha.hex(), "purpose": kind.name},
|
||||
ip=getattr(actor, "ip", None),
|
||||
)
|
||||
return public(dict(row))
|
||||
placed = None # the row is written: the file stays
|
||||
except BaseException:
|
||||
if placed is not None: # the row was not written: do not leave an orphan file
|
||||
with contextlib.suppress(OSError):
|
||||
@@ -258,6 +461,8 @@ def store_upload(
|
||||
if not keep:
|
||||
with contextlib.suppress(OSError):
|
||||
tmp.unlink()
|
||||
quota_after_store(conn, settings)
|
||||
return public(dict(row))
|
||||
|
||||
|
||||
def store_file(
|
||||
@@ -276,9 +481,10 @@ def store_file(
|
||||
|
||||
The file is consumed: renamed into ASSETS_DIR when its bytes are new, deleted when the same bytes are already
|
||||
stored (same sha256 and size; that row comes back). Write it under ASSETS_DIR (ASSETS_DIR/tmp) so the rename
|
||||
stays on one file system. It is hashed in chunks, its first bytes must match `mime`, and it is fsynced before
|
||||
the rename. The row carries one extra key, `reused`. No audit row: the caller writes its own.
|
||||
Raises 413 too_large, 415 unsupported_type, 422 bad_pdf.
|
||||
stays on one file system. It is hashed in chunks, its content must match `mime`, it is fsynced, and it is
|
||||
scanned when ClamAV is configured, before the dedupe lookup and the rename. The row carries one extra key,
|
||||
`reused`. No audit row for a stored file (the caller writes its own); a virus hit writes `asset.rejected`.
|
||||
Raises 413 too_large, 415 unsupported_type, 422 bad_pdf, 422 infected, 503 scan_unavailable.
|
||||
"""
|
||||
path = Path(path)
|
||||
limit = int(settings.upload_max_bytes)
|
||||
@@ -297,11 +503,13 @@ def store_file(
|
||||
head += chunk[: HEAD - len(head)]
|
||||
size += len(chunk)
|
||||
if size > limit:
|
||||
raise ApiError(413, "too_large", f"The file is over the {limit // (1024 * 1024)} MB limit.")
|
||||
raise too_large(limit)
|
||||
digest.update(chunk)
|
||||
os.fsync(f.fileno())
|
||||
if size == 0 or sniff(head) != mime:
|
||||
found = sniff(head, path, purpose="bom" if mime == CSV else None) if size else None
|
||||
if found != mime:
|
||||
raise ApiError(415, "unsupported_type", f"The file is not {EXT.get(mime, mime)}.")
|
||||
_scan(conn, settings, path, user=uploaded_by, actor=None, packet_id=packet_id, size=size, mime=mime)
|
||||
sha = digest.digest()
|
||||
existing = _existing(conn, sha, size)
|
||||
if existing is not None:
|
||||
@@ -312,7 +520,6 @@ def store_file(
|
||||
with conn.transaction():
|
||||
row = _insert(conn, (aid, sha, size, mime, name, key, source, source_ref, pages, uploaded_by, packet_id))
|
||||
done = True
|
||||
return {**row, "reused": False}
|
||||
except BaseException:
|
||||
if placed is not None and not done: # the row was not written: do not leave an orphan file
|
||||
with contextlib.suppress(OSError):
|
||||
@@ -321,6 +528,109 @@ def store_file(
|
||||
finally:
|
||||
with contextlib.suppress(OSError):
|
||||
path.unlink()
|
||||
quota_after_store(conn, settings)
|
||||
return {**row, "reused": False}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- quota
|
||||
|
||||
QUOTA_KIND = "assets_quota"
|
||||
QUOTA_SUBJECT = "assets"
|
||||
|
||||
|
||||
def _stored_bytes(conn: Any) -> int:
|
||||
r = conn.execute("SELECT coalesce(sum(size), 0) FROM assets").fetchone()
|
||||
return int(r[0]) if r else 0
|
||||
|
||||
|
||||
def volume_usage(settings: Settings, conn: Any = None) -> dict[str, Any]:
|
||||
"""How full the assets store is: {"total", "used", "free", "pct", "warn"} in bytes and percent.
|
||||
|
||||
With ASSETS_QUOTA_BYTES at 0 this is the assets volume (shutil.disk_usage). Otherwise the total is the quota
|
||||
and used is the sum of assets.size (read through `conn`; without one, the bytes on disk under ASSETS_DIR).
|
||||
`warn` is pct at or over ASSETS_QUOTA_WARN_PCT.
|
||||
"""
|
||||
root = Path(settings.assets_dir)
|
||||
probe = root if root.exists() else root.parent
|
||||
quota = int(settings.assets_quota_bytes)
|
||||
if quota <= 0:
|
||||
du = shutil.disk_usage(probe)
|
||||
total, used, free = int(du.total), int(du.used), int(du.free)
|
||||
else:
|
||||
used = _stored_bytes(conn) if conn is not None else _bytes_on_disk(root)
|
||||
total = quota
|
||||
free = max(0, quota - used)
|
||||
pct = round(used * 100.0 / total, 1) if total > 0 else 0.0
|
||||
return {"total": total, "used": used, "free": free, "pct": pct, "warn": pct >= int(settings.assets_quota_warn_pct)}
|
||||
|
||||
|
||||
def _bytes_on_disk(root: Path) -> int:
|
||||
n = 0
|
||||
for dirpath, _dirs, files in os.walk(root):
|
||||
for f in files:
|
||||
with contextlib.suppress(OSError):
|
||||
n += os.stat(os.path.join(dirpath, f)).st_size
|
||||
return n
|
||||
|
||||
|
||||
def usage_for(conn: Any, settings: Settings) -> dict[str, Any]:
|
||||
"""volume_usage, given the connection only when the quota is a byte count (the sum of assets.size needs it)."""
|
||||
if int(settings.assets_quota_bytes) > 0:
|
||||
return volume_usage(settings, conn)
|
||||
return volume_usage(settings)
|
||||
|
||||
|
||||
def open_quota_alert(conn: Any) -> dict[str, Any] | None:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
r = cur.execute(
|
||||
"SELECT * FROM notification_events WHERE kind = %s AND coalesce(subject, '') = %s"
|
||||
" AND resolved_at IS NULL ORDER BY id LIMIT 1",
|
||||
(QUOTA_KIND, QUOTA_SUBJECT),
|
||||
).fetchone()
|
||||
return dict(r) if r else None
|
||||
|
||||
|
||||
def check_quota(conn: Any, settings: Settings) -> dict[str, Any]:
|
||||
"""Open or resolve the storage alert. Returns the usage with `alert`: "opened", "resolved" or None.
|
||||
|
||||
Over the threshold with no open `assets_quota` row: insert one (severity warning, subject assets, detail the
|
||||
usage) and audit `storage.quota_warning`. Under it with one open: set its resolved_at.
|
||||
"""
|
||||
usage = usage_for(conn, settings)
|
||||
change: str | None = None
|
||||
with conn.transaction():
|
||||
open_row = open_quota_alert(conn)
|
||||
if usage["warn"] and open_row is None:
|
||||
r = conn.execute(
|
||||
"INSERT INTO notification_events (kind, severity, subject, detail) VALUES (%s, 'warning', %s, %s)"
|
||||
" ON CONFLICT (kind, coalesce(subject, '')) WHERE resolved_at IS NULL DO NOTHING RETURNING id",
|
||||
(QUOTA_KIND, QUOTA_SUBJECT, Jsonb(usage)),
|
||||
).fetchone()
|
||||
if r is not None:
|
||||
change = "opened"
|
||||
audit.append(
|
||||
conn,
|
||||
action="storage.quota_warning",
|
||||
subject_type="notification",
|
||||
subject_id=str(r[0]),
|
||||
detail={**usage, "warn_pct": int(settings.assets_quota_warn_pct)},
|
||||
)
|
||||
log.warning(
|
||||
"assets storage is %.1f%% full (warning at %s%%)", usage["pct"], settings.assets_quota_warn_pct
|
||||
)
|
||||
elif not usage["warn"] and open_row is not None:
|
||||
conn.execute("UPDATE notification_events SET resolved_at = now() WHERE id = %s", (open_row["id"],))
|
||||
change = "resolved"
|
||||
log.info("assets storage is back under the warning: %.1f%%", usage["pct"])
|
||||
return {**usage, "alert": change}
|
||||
|
||||
|
||||
def quota_after_store(conn: Any, settings: Settings) -> None:
|
||||
"""check_quota after a store. A failure is logged and never fails the store."""
|
||||
try:
|
||||
check_quota(conn, settings)
|
||||
except Exception:
|
||||
log.exception("the storage quota check failed")
|
||||
|
||||
|
||||
def open_blob(settings: Settings, row: dict[str, Any]) -> Path:
|
||||
@@ -339,3 +649,9 @@ def download_name(row: dict[str, Any]) -> str:
|
||||
if not name:
|
||||
name = str(row["id"]) + EXT.get(str(row["mime"]), "")
|
||||
return name
|
||||
|
||||
|
||||
def disposition(row: dict[str, Any], download: bool = False) -> str:
|
||||
"""inline for PDF and images unless a download is asked for; XLSX and CSV are always attachment."""
|
||||
kind = "attachment" if download or str(row["mime"]) in ATTACHMENT_ONLY else "inline"
|
||||
return f'{kind}; filename="{download_name(row)}"'
|
||||
|
||||
@@ -288,6 +288,23 @@ def load_children(conn: Any, row: Mapping[str, Any], *, full: bool = True) -> Ch
|
||||
return ch
|
||||
|
||||
|
||||
def is_sub(line: Mapping[str, Any]) -> bool:
|
||||
"""A sub-assembly line: made on its own packet, so its heats are recorded there."""
|
||||
return str(line.get("sub") or "").strip().upper() == "Y"
|
||||
|
||||
|
||||
def needs_heat(line: Mapping[str, Any]) -> bool:
|
||||
"""The old needsHeat for a pulled line: not N/A, not a sub-assembly, pieces issued and no heat."""
|
||||
if line.get("na") or is_sub(line) or not line.get("pulled"):
|
||||
return False
|
||||
if str(line.get("heat") or "").strip():
|
||||
return False
|
||||
try:
|
||||
return float(line.get("issued") or 0) > 0
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def state_from(row: Mapping[str, Any], ch: Children) -> policy.PacketState:
|
||||
weld = bool(ch.dept.get("weld"))
|
||||
live_bom = [b for b in ch.bom if not b["na"]]
|
||||
@@ -311,6 +328,7 @@ def state_from(row: Mapping[str, Any], ch: Children) -> policy.PacketState:
|
||||
heat_map=row["heat_map"],
|
||||
bom_total=len(live_bom),
|
||||
bom_ready=sum(1 for b in live_bom if b["pulled"] or b["id"] in ch.shorts),
|
||||
bom_missing_heat=sum(1 for b in live_bom if needs_heat(b)),
|
||||
welds=len(ch.welds),
|
||||
checklist_complete=stage is not None and checklists.checklist_complete(stage["checklist"], weld),
|
||||
filler_set=stage is not None and bool((stage["filler"] or "").strip()),
|
||||
@@ -661,6 +679,9 @@ def build(
|
||||
if seal:
|
||||
for k in SEAL_EXCLUDE:
|
||||
out.pop(k, None)
|
||||
# `sub` joined the BOM lines in phase 4; seals made before that do not cover it.
|
||||
for b in out.get("bom") or []:
|
||||
b.pop("sub", None)
|
||||
return out
|
||||
|
||||
|
||||
@@ -681,6 +702,7 @@ def _bom(b: Mapping[str, Any], ch: Children, people: People) -> dict[str, Any]:
|
||||
"loc",
|
||||
"pulled",
|
||||
"src",
|
||||
"sub",
|
||||
"row_version",
|
||||
)
|
||||
out = {k: b[k] for k in keys}
|
||||
@@ -788,7 +810,9 @@ SELECT p.id, p.version, p.unit_id, u.lot, pr.number || u.lot AS unit_number, pr.
|
||||
p.visual_closed_at, p.visual_cancelled_at, p.wo_gone_at,
|
||||
EXISTS (SELECT 1 FROM change_orders c WHERE c.packet_id = p.id AND c.review = 'pending'
|
||||
AND c.origin IN ('visual', 'drawing')) AS co_pending,
|
||||
(SELECT w.stage::text FROM packet_wh w WHERE w.packet_id = p.id AND w.closed_at IS NULL) AS wh_stage,
|
||||
pw.stage::text AS wh_stage, pw.by AS wh_by, whu.display_name AS wh_by_name, pw.at AS wh_at,
|
||||
pw.reason AS wh_reason, pw.note AS wh_note, pw.auto AS wh_auto, p.wh_sent_at, p.visual_rel,
|
||||
bc.bom_total, bc.bom_pulled, bc.bom_missing_heat,
|
||||
(SELECT count(*) FROM bom_shorts s WHERE s.packet_id = p.id AND s.closed_at IS NULL) AS open_shorts,
|
||||
(SELECT count(*) FROM packet_work_orders wo WHERE wo.packet_id = p.id) AS wo_count,
|
||||
(SELECT coalesce(json_agg(json_build_object('co_no', c.co_no, 'review', c.review,
|
||||
@@ -801,6 +825,16 @@ JOIN departments d ON d.code = p.dept_code
|
||||
LEFT JOIN stations st ON st.id = p.station_id
|
||||
LEFT JOIN users wu ON wu.id = p.welder_id
|
||||
LEFT JOIN users vu ON vu.id = p.vclosed_by
|
||||
LEFT JOIN packet_wh pw ON pw.packet_id = p.id AND pw.closed_at IS NULL
|
||||
LEFT JOIN users whu ON whu.id = pw.by
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT count(*) FILTER (WHERE NOT b.na) AS bom_total,
|
||||
count(*) FILTER (WHERE NOT b.na AND (b.pulled OR EXISTS (SELECT 1 FROM bom_shorts s
|
||||
WHERE s.bom_line_id = b.id AND s.closed_at IS NULL))) AS bom_pulled,
|
||||
count(*) FILTER (WHERE NOT b.na AND upper(btrim(coalesce(b.sub, ''))) <> 'Y' AND b.pulled
|
||||
AND coalesce(b.issued, 0) > 0 AND btrim(coalesce(b.heat, '')) = '') AS bom_missing_heat
|
||||
FROM bom_lines b WHERE b.packet_id = p.id
|
||||
) bc ON true
|
||||
"""
|
||||
|
||||
FLAG_ON = ("1", "true", "yes", "any", "flagged")
|
||||
@@ -842,11 +876,27 @@ def card_state(r: Mapping[str, Any], names: Mapping[str, str]) -> policy.PacketS
|
||||
)
|
||||
|
||||
|
||||
def list_cards(conn: Any, policy_principal: policy.Principal, filters: Mapping[str, Any]) -> list[dict[str, Any]]:
|
||||
"""The board cards. Filters: status (comma list), dept, unit_id, project_id, q, flag (1|0|hand|hand_upload|
|
||||
<kind>), co (pending), limit, removed."""
|
||||
def wh_card(r: Mapping[str, Any]) -> dict[str, Any] | None:
|
||||
"""The current warehouse stage row of a card, in the read model's `wh` shape."""
|
||||
if r["wh_stage"] is None:
|
||||
return None
|
||||
return {
|
||||
"stage": r["wh_stage"],
|
||||
"by": None if r["wh_by"] is None else {"id": str(r["wh_by"]), "display_name": r["wh_by_name"]},
|
||||
"at": r["wh_at"],
|
||||
"reason": r["wh_reason"],
|
||||
"note": r["wh_note"],
|
||||
"auto": bool(r["wh_auto"]),
|
||||
}
|
||||
|
||||
|
||||
def card_where(filters: Mapping[str, Any]) -> tuple[list[str], list[Any]]:
|
||||
"""The WHERE terms and parameters of the card filters, over CARD_SQL's aliases (p, u, pr)."""
|
||||
where: list[str] = []
|
||||
params: list[Any] = []
|
||||
if filters.get("ids") is not None:
|
||||
where.append("p.id = ANY(%s)")
|
||||
params.append([int(i) for i in filters["ids"]])
|
||||
if not filters.get("removed"):
|
||||
where.append("p.removed_at IS NULL")
|
||||
status = [s.strip() for s in str(filters.get("status") or "").split(",") if s.strip()]
|
||||
@@ -895,6 +945,13 @@ def list_cards(conn: Any, policy_principal: policy.Principal, filters: Mapping[s
|
||||
" AND wo.wo_base ILIKE %s))"
|
||||
)
|
||||
params += [pat] * 10
|
||||
return where, params
|
||||
|
||||
|
||||
def list_cards(conn: Any, policy_principal: policy.Principal, filters: Mapping[str, Any]) -> list[dict[str, Any]]:
|
||||
"""The board cards. Filters: status (comma list), dept, unit_id, project_id, q, flag (1|0|hand|hand_upload|
|
||||
<kind>), co (pending), ids (packet ids), limit, removed."""
|
||||
where, params = card_where(filters)
|
||||
sql = CARD_SQL + (" WHERE " + " AND ".join(where) if where else "")
|
||||
sql += " ORDER BY p.updated_at DESC, p.id DESC LIMIT %s"
|
||||
params.append(int(filters.get("limit") or 500))
|
||||
@@ -943,6 +1000,12 @@ def list_cards(conn: Any, policy_principal: policy.Principal, filters: Mapping[s
|
||||
"open_cos": [c["co_no"] for c in r["open_co_rows"] or []],
|
||||
"open_shorts": int(r["open_shorts"] or 0),
|
||||
"wh_stage": r["wh_stage"],
|
||||
"wh": wh_card(r),
|
||||
"wh_sent_at": r["wh_sent_at"],
|
||||
"visual_rel": r["visual_rel"],
|
||||
"bom_total": int(r["bom_total"] or 0),
|
||||
"bom_pulled": int(r["bom_pulled"] or 0),
|
||||
"bom_missing_heat": int(r["bom_missing_heat"] or 0),
|
||||
"due_effective": r["due_effective"],
|
||||
"updated_at": r["updated_at"],
|
||||
"holder": policy.holder_name(state, r["dept_name"], r["station_name"]),
|
||||
|
||||
@@ -57,7 +57,7 @@ LABELS: dict[policy.Action, str] = {
|
||||
A.SEND_DRAFT_TO_QC: "Sent straight to Q.C.",
|
||||
A.WH_SEND_BACK: "Sent back to Clerical",
|
||||
A.RETURN_TO_CLERICAL: "Sent back to Clerical",
|
||||
A.WH_RELEASE: "Released to the shop",
|
||||
A.WH_RELEASE: "Released to supervisor",
|
||||
A.SEND_BACK_TO_WAREHOUSE: "Sent back to the warehouse",
|
||||
A.ASSIGN: "Sent to a station",
|
||||
A.UNASSIGN: "Taken off the station",
|
||||
@@ -528,6 +528,41 @@ def _seal_key(w: Write) -> bytes:
|
||||
return key
|
||||
|
||||
|
||||
def qty_text(value: Any) -> str:
|
||||
"""A quantity without trailing zeros: 4, 2.5."""
|
||||
if value is None:
|
||||
return "0"
|
||||
try:
|
||||
f = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
return str(int(f)) if f.is_integer() else f"{f:g}"
|
||||
|
||||
|
||||
def _release_text(w: Write) -> None:
|
||||
"""The old release label and note.
|
||||
|
||||
Label "Released to supervisor", or "... with {n} short"; note "{heats} heats recorded", plus " · SHORT: ..."
|
||||
when shorts are open.
|
||||
"""
|
||||
lines = packets.rows(
|
||||
w.conn,
|
||||
"SELECT b.item, b.heat, s.qty_open, s.reason FROM bom_lines b LEFT JOIN bom_shorts s"
|
||||
" ON s.bom_line_id = b.id AND s.closed_at IS NULL WHERE b.packet_id = %s ORDER BY b.item",
|
||||
(w.id,),
|
||||
)
|
||||
heats = sum(1 for b in lines if str(b["heat"] or "").strip())
|
||||
shorts = [b for b in lines if b["qty_open"] is not None]
|
||||
note = f"{heats} heats recorded"
|
||||
if shorts:
|
||||
note += " · SHORT: " + ", ".join(
|
||||
f"item {b['item']} ×{qty_text(b['qty_open'])} ({b['reason'] or ''})" for b in shorts
|
||||
)
|
||||
w.log(f"{LABELS[A.WH_RELEASE]} with {len(shorts)} short", note)
|
||||
else:
|
||||
w.log(LABELS[A.WH_RELEASE], note)
|
||||
|
||||
|
||||
def apply_transition(w: Write) -> None:
|
||||
"""Set status, status_at and dept, write the packet_transitions row and run the action's side effects."""
|
||||
if w.transitioned:
|
||||
@@ -569,6 +604,7 @@ def apply_transition(w: Write) -> None:
|
||||
_set_packet(w, "return_note = %s", (reason,))
|
||||
_close_stage(w)
|
||||
elif a is A.WH_RELEASE:
|
||||
_release_text(w)
|
||||
_wh_close(w)
|
||||
_close_stage(w)
|
||||
_open_stage(w, to_dept)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Virus scan through clamd (ClamAV), optional. Off when CLAMAV_HOST is empty.
|
||||
|
||||
The file is streamed to clamd with zINSTREAM: the command, then chunks each led by a 4-byte big-endian length, then a
|
||||
zero length. clamd answers `stream: OK` or `stream: <signature> FOUND`. When a scanner is configured and cannot be
|
||||
reached, the upload is refused (fail closed).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import socket
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..api.errors import ApiError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CHUNK = 256 * 1024
|
||||
REPLY_MAX = 4096
|
||||
MSG_INFECTED = "That file failed the virus scan and was not stored."
|
||||
MSG_UNAVAILABLE = "The virus scanner is not reachable. Try again in a minute."
|
||||
|
||||
|
||||
class ScanError(Exception):
|
||||
"""The scanner could not be reached or answered badly."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScanResult:
|
||||
clean: bool
|
||||
signature: str | None
|
||||
|
||||
|
||||
class ClamdScanner:
|
||||
def __init__(self, host: str, port: int = 3310, timeout: float = 30.0):
|
||||
self.host = host
|
||||
self.port = int(port)
|
||||
self.timeout = float(timeout)
|
||||
|
||||
def _connect(self, timeout: float | None = None) -> socket.socket:
|
||||
try:
|
||||
return socket.create_connection((self.host, self.port), timeout=timeout or self.timeout)
|
||||
except OSError as e:
|
||||
raise ScanError(f"can't reach clamd at {self.host}:{self.port}: {type(e).__name__}") from None
|
||||
|
||||
@staticmethod
|
||||
def _reply(sock: socket.socket) -> str:
|
||||
"""Read one NUL-terminated reply (or up to the close)."""
|
||||
buf = b""
|
||||
while b"\0" not in buf and len(buf) < REPLY_MAX:
|
||||
part = sock.recv(1024)
|
||||
if not part:
|
||||
break
|
||||
buf += part
|
||||
return buf.split(b"\0", 1)[0].decode("utf-8", "replace").strip()
|
||||
|
||||
def ping(self, timeout: float | None = None) -> bool:
|
||||
"""True when clamd answers PING with PONG."""
|
||||
try:
|
||||
with self._connect(timeout) as sock:
|
||||
sock.sendall(b"zPING\0")
|
||||
return self._reply(sock) == "PONG"
|
||||
except (ScanError, OSError) as e:
|
||||
log.info("clamd ping failed: %s", type(e).__name__)
|
||||
return False
|
||||
|
||||
def scan_path(self, path: Path) -> ScanResult:
|
||||
"""Stream the file to clamd. Raises ScanError when clamd is down or its answer is not OK or FOUND."""
|
||||
try:
|
||||
with self._connect() as sock, Path(path).open("rb") as f:
|
||||
sock.sendall(b"zINSTREAM\0")
|
||||
while True:
|
||||
chunk = f.read(CHUNK)
|
||||
if not chunk:
|
||||
break
|
||||
sock.sendall(struct.pack(">I", len(chunk)) + chunk)
|
||||
sock.sendall(struct.pack(">I", 0))
|
||||
reply = self._reply(sock)
|
||||
except ScanError:
|
||||
raise
|
||||
except OSError as e:
|
||||
raise ScanError(f"clamd scan failed: {type(e).__name__}") from None
|
||||
return parse_reply(reply)
|
||||
|
||||
|
||||
def parse_reply(reply: str) -> ScanResult:
|
||||
"""`stream: OK` is clean; `stream: <sig> FOUND` is a hit; anything else is a ScanError."""
|
||||
text = reply.strip()
|
||||
if text.startswith("stream:"):
|
||||
text = text[len("stream:") :].strip()
|
||||
if text == "OK":
|
||||
return ScanResult(clean=True, signature=None)
|
||||
if text.endswith(" FOUND"):
|
||||
sig = text[: -len(" FOUND")].strip()[:200]
|
||||
return ScanResult(clean=False, signature=sig or "unknown")
|
||||
raise ScanError(f"clamd answered: {text[:200]}")
|
||||
|
||||
|
||||
def scanner_for(settings: Any) -> ClamdScanner | None:
|
||||
"""The configured scanner, or None when CLAMAV_HOST is empty."""
|
||||
host = str(getattr(settings, "clamav_host", "") or "").strip()
|
||||
if not host:
|
||||
return None
|
||||
return ClamdScanner(
|
||||
host,
|
||||
int(getattr(settings, "clamav_port", 3310)),
|
||||
float(getattr(settings, "clamav_timeout_seconds", 30.0)),
|
||||
)
|
||||
|
||||
|
||||
def check(settings: Any, path: Path) -> ScanResult | None:
|
||||
"""Scan a complete file. None when no scanner is configured.
|
||||
|
||||
Raises 422 infected (with the signature) on a hit and 503 scan_unavailable when the scanner fails.
|
||||
"""
|
||||
scanner = scanner_for(settings)
|
||||
if scanner is None:
|
||||
return None
|
||||
try:
|
||||
result = scanner.scan_path(path)
|
||||
except ScanError as e:
|
||||
log.warning("virus scan unavailable: %s", e)
|
||||
raise ApiError(503, "scan_unavailable", MSG_UNAVAILABLE) from None
|
||||
if not result.clean:
|
||||
log.warning("upload refused by the virus scan: %s", result.signature)
|
||||
raise ApiError(422, "infected", MSG_INFECTED, extra={"signature": result.signature})
|
||||
return result
|
||||
@@ -106,6 +106,7 @@ def create_app(
|
||||
packets,
|
||||
projects,
|
||||
units,
|
||||
warehouse,
|
||||
)
|
||||
from .api import events as events_api
|
||||
|
||||
@@ -123,6 +124,7 @@ def create_app(
|
||||
assets,
|
||||
jobs,
|
||||
feed_settings,
|
||||
warehouse,
|
||||
):
|
||||
app.include_router(module.router)
|
||||
app.include_router(assets.blob_router)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
-- 0005_warehouse: the notification placeholder. Phase 4 writes the storage quota alert here; phase 9 builds the
|
||||
-- delivery (mail, the bell) on this table. Runs as ps_migrate; 0001's default privileges give ps_app SELECT,
|
||||
-- INSERT, UPDATE, DELETE on it.
|
||||
|
||||
-- One row per alert. An alert is open until resolved_at is set. Acknowledging it does not resolve it: the
|
||||
-- condition that raised it has to clear.
|
||||
CREATE TABLE notification_events (
|
||||
id bigserial PRIMARY KEY,
|
||||
kind text NOT NULL,
|
||||
severity text NOT NULL DEFAULT 'warning' CHECK (severity IN ('info', 'warning', 'critical')),
|
||||
subject text,
|
||||
detail jsonb NOT NULL DEFAULT '{}',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
resolved_at timestamptz,
|
||||
acknowledged_at timestamptz,
|
||||
acknowledged_by uuid REFERENCES users (id)
|
||||
);
|
||||
|
||||
-- At most one open alert per kind and subject.
|
||||
CREATE UNIQUE INDEX notification_events_open ON notification_events (kind, coalesce(subject, ''))
|
||||
WHERE resolved_at IS NULL;
|
||||
CREATE INDEX notification_events_created ON notification_events (created_at DESC);
|
||||
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ps_app;
|
||||
@@ -162,7 +162,9 @@ RULES: dict[Action, Rule] = {
|
||||
A.SEND_DRAFT_TO_QC: Rule(Who.CL, _s("draft"), "qc", preconditions=_DRAFT_OUT),
|
||||
A.WH_SEND_BACK: Rule(Who.WH, _s("warehouse"), "draft", preconditions=_REASON, reason_required=True),
|
||||
A.RETURN_TO_CLERICAL: Rule(Who.DEPT_LEAD, _s("ready"), "draft", preconditions=_REASON, reason_required=True),
|
||||
A.WH_RELEASE: Rule(Who.WH, _s("warehouse"), "ready", co_hold=True, preconditions=("bom_all_pulled_or_short",)),
|
||||
A.WH_RELEASE: Rule(
|
||||
Who.WH, _s("warehouse"), "ready", co_hold=True, preconditions=("bom_all_pulled_or_short", "heats_recorded")
|
||||
),
|
||||
A.SEND_BACK_TO_WAREHOUSE: Rule(
|
||||
Who.DEPT_LEAD, _s("ready"), "warehouse", preconditions=_REASON, reason_required=True
|
||||
),
|
||||
|
||||
@@ -19,6 +19,9 @@ MESSAGES: dict[str, str] = {
|
||||
"heat_map_answered": "Say whether this packet needs a heat map.",
|
||||
"drawing_attached": "Attach the drawing first.",
|
||||
"bom_all_pulled_or_short": "Pull every material line or mark it short first.",
|
||||
"heats_recorded": (
|
||||
"Scan or type the heat number of every pulled line first. Use N/A for a part with no traceability."
|
||||
),
|
||||
"short_open": "No part is short on this packet.",
|
||||
"welder_set": "Pick a station and a welder.",
|
||||
"checklist_complete": "Finish the checklist first.",
|
||||
@@ -64,6 +67,11 @@ def bom_all_pulled_or_short(state: PacketState, extra: Mapping[str, Any]) -> str
|
||||
return _fail("bom_all_pulled_or_short", state.bom_ready >= state.bom_total)
|
||||
|
||||
|
||||
def heats_recorded(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
||||
"""Every pulled line that issued pieces has a heat, unless it is N/A or a sub-assembly."""
|
||||
return _fail("heats_recorded", state.bom_missing_heat <= 0)
|
||||
|
||||
|
||||
def short_open(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
||||
"""The old whCanReceive: the warehouse may only receive into a packet with an open short."""
|
||||
return _fail("short_open", state.open_shorts > 0)
|
||||
@@ -131,6 +139,7 @@ PRECONDITIONS: dict[str, Precondition] = {
|
||||
"heat_map_answered": heat_map_answered,
|
||||
"drawing_attached": drawing_attached,
|
||||
"bom_all_pulled_or_short": bom_all_pulled_or_short,
|
||||
"heats_recorded": heats_recorded,
|
||||
"short_open": short_open,
|
||||
"welder_set": welder_set,
|
||||
"checklist_complete": checklist_complete,
|
||||
|
||||
@@ -49,6 +49,7 @@ class PacketState:
|
||||
heat_map: bool | None = None
|
||||
bom_total: int = 0 # lines not marked n/a
|
||||
bom_ready: int = 0 # lines pulled, or with an open short
|
||||
bom_missing_heat: int = 0 # pulled lines with pieces issued and no heat (not n/a, not a sub-assembly)
|
||||
welds: int = 0
|
||||
checklist_complete: bool = False # every key of the department checklist is done (empty list is complete)
|
||||
filler_set: bool = False
|
||||
|
||||
@@ -102,6 +102,15 @@ class Settings(BaseSettings):
|
||||
pin_min_digits: int = Field(default=6, ge=4)
|
||||
pin_max_digits: int = Field(default=12, ge=4)
|
||||
upload_max_bytes: int = Field(default=25 * 1024 * 1024, ge=1024)
|
||||
upload_max_bytes_bom: int = Field(default=5 * 1024 * 1024, ge=1024)
|
||||
|
||||
# ClamAV (clamd). Empty host means no virus scan. When set, an unreachable scanner refuses uploads.
|
||||
clamav_host: str = ""
|
||||
clamav_port: int = Field(default=3310, ge=1, le=65535)
|
||||
clamav_timeout_seconds: float = Field(default=30.0, gt=0, le=600)
|
||||
# The storage alert: warn at this percent of the assets volume, or of ASSETS_QUOTA_BYTES when that is set.
|
||||
assets_quota_warn_pct: int = Field(default=85, ge=1, le=99)
|
||||
assets_quota_bytes: int = Field(default=0, ge=0)
|
||||
|
||||
# Where the worker reads VISUAL and M-Files from. `fixture` reads synthetic files under FIXTURE_DIR (CI and tests).
|
||||
feed_source: FeedSource = "visualkit"
|
||||
@@ -119,6 +128,8 @@ class Settings(BaseSettings):
|
||||
for key in list(data):
|
||||
if key.lower() in ("stack", "feed_source", "drawing_source") and isinstance(data[key], str):
|
||||
data[key] = data[key].strip().lower()
|
||||
elif key.lower() == "clamav_host" and isinstance(data[key], str):
|
||||
data[key] = data[key].strip()
|
||||
return data
|
||||
|
||||
@field_validator("log_level")
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Housekeeping, nightly at 03:30 site time: prune the VISUAL change rows and gone cache rows, old job runs and
|
||||
old claimed run requests. It never touches packets, the audit log or anything append-only."""
|
||||
old claimed run requests. It never touches packets, the audit log or anything append-only. Then it checks the assets
|
||||
storage against its warning threshold (assets.check_quota opens or resolves the alert)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, time
|
||||
|
||||
from ...domain import assets
|
||||
from ...feed.config import FeedSettings
|
||||
from . import Counts, JobContext, JobSpec, seconds_until
|
||||
|
||||
@@ -50,6 +52,16 @@ def run(ctx: JobContext) -> Counts:
|
||||
for name, sql, days in STEPS:
|
||||
with ctx.conn.transaction():
|
||||
counts[name] = ctx.conn.execute(sql, (days,)).rowcount
|
||||
try:
|
||||
usage = assets.check_quota(ctx.conn, ctx.settings)
|
||||
except Exception as e: # the storage check never fails the pruning
|
||||
ctx.log.exception("the storage quota check failed")
|
||||
counts["quota_error"] = type(e).__name__
|
||||
else:
|
||||
counts["quota_pct"] = usage["pct"]
|
||||
counts["quota_warn"] = usage["warn"]
|
||||
if usage["alert"]:
|
||||
counts["quota_alert"] = usage["alert"]
|
||||
return counts
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user