Files
spencermandClaude Fable 5.1 39a4eb5213
ci / frontend (push) Successful in 30s
ci / server (push) Successful in 4m30s
ci / image (push) Successful in 35s
ci / e2e (push) Successful in 1m40s
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]>
2026-10-16 17:13:31 -06:00

111 lines
4.1 KiB
Python

"""Stored files: upload (multipart), metadata, and the same-origin blob route.
The upload only needs a session; the attach endpoints (packet pages, documents) check the policy. The form is read
after the session check, so a request without a session never gets its body spooled.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query, Request, Response
from fastapi.responses import FileResponse
from starlette.concurrency import run_in_threadpool
from starlette.datastructures import UploadFile
from ..domain import assets
from .deps import Db, Principal, current_principal, get_db, settings_of
from .errors import ApiError
router = APIRouter(prefix="/api/assets", tags=["assets"])
blob_router = APIRouter(tags=["assets"])
# Multipart overhead allowed on top of the file limit before the body is refused unread.
FORM_SLACK = 64 * 1024
@router.post("", status_code=201)
async def upload(
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
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) > pre + FORM_SLACK:
raise assets.too_large(pre)
form = await request.form(max_files=1, max_fields=8)
try:
file = form.get("file")
if not isinstance(file, UploadFile):
raise ApiError(422, "invalid_request", "Send the file in the 'file' field.")
packet_id: int | None = None
raw_pid = form.get("packet_id")
if isinstance(raw_pid, str) and raw_pid.strip():
if not raw_pid.strip().isdigit():
raise ApiError(422, "invalid_request", "packet_id must be a number.")
packet_id = int(raw_pid.strip())
exists = await run_in_threadpool(
lambda: d.conn.execute("SELECT 1 FROM packets WHERE id = %s", (packet_id,)).fetchone()
)
if exists is None:
raise ApiError(404, "not_found", "No such packet.")
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(
d.conn,
s,
upload_file.file,
original_name=upload_file.filename,
user=p.user_id,
packet_id=packet_id,
actor=p,
purpose=kind.name,
)
)
finally:
await form.close()
@router.get("/{asset_id}")
def metadata(
asset_id: str,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
row = assets.get(d.conn, asset_id)
if row is None:
raise ApiError(404, "not_found", "No such file.")
return assets.public(row)
@blob_router.get("/_blob/{asset_id}")
def blob(
asset_id: str,
request: Request,
download: int = Query(0, ge=0, le=1),
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> Response:
row = assets.get(d.conn, asset_id)
if row is None:
raise ApiError(404, "not_found", "No such file.")
path = assets.open_blob(settings_of(request), row)
etag = '"' + bytes(row["sha256"]).hex() + '"'
headers = {
"Content-Disposition": assets.disposition(row, bool(download)),
"X-Content-Type-Options": "nosniff",
"Cache-Control": "private, max-age=3600",
"ETag": etag,
}
if request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers=headers)
return FileResponse(path, media_type=str(row["mime"]), headers=headers)