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]>
111 lines
4.1 KiB
Python
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)
|