T7.7 - CR-007/D8: the sheet travels with the package, and opens offline

The field wants the specific PDF attached, not a link to a Bluebeam session.

Storage: a new wp_files table (Alembic f3a9d2c1e8b7, additive only) holding
the BYTES in the same database as everything else - the Aug 18 decision: a
backup that excludes the drawings is a backup you cannot restore from. The
D8 numbers bound the cost and are enforced ON THE SERVER as well as in the
browser: 5MB a file (413, naming the limit), PDF and image mimes only (400,
naming what is accepted), 2GB a project (413 naming the ceiling; response
flags the 80% warning). The ceiling is env-overridable for tests; the shipped
default is the decision, asserted from source.

The package record carries a server-owned meta mirror (data.files): the
upload/patch/delete routes rewrite it, and the upsert re-asserts the stored
copy over whatever a client sends - a save from a browser that had not seen
an upload land cannot erase the list.

Creator: uploads live beside the links (links still work), the limits and the
running project total sit ABOVE the picker (amber from 80%, red at full), a
refused file costs nothing but a toast and never leaves the browser (the
probe counts fetch calls), and each drawing has a description ("Tray section,
Level 3 east only") editable inline and persisted server-side. Uploads attach
to the saved record, so T4.3's autosave keeps the surrounding form safe (X8).

Export: uploads print with the package - name, size tag, description on the
attachments table, images inline as the sheet itself, PDFs as links.

Offline (D8): the service worker gains a drawings cache (cache-first on
/api/files/), and field.js prefetches ONLY the requesting user's assigned
packages - assignment-scoped by decision, not project-wide. The probe's first
offline check used CDP network emulation and PASSED FOR THE WRONG REASON: the
emulation binds to the page's session and the service worker fetches on its
own target, straight past it. The shipped check kills the server instead -
my drawing opens, the other package's does not, against a genuinely dead
network.

Field View: a Drawings section on the package detail, 44px rows, description
inline, inside the 390px screen.

Verification (each probe run alone): NEW tests/files_check.py 36/36; the
Alembic chain applied end-to-end to a scratch DB and the table verified.
Regressions: form_structure_check 50/51 (the standing F6 height gap),
frame_check 39/39.

Items: CR-007, D8 (X8 honored)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 11:09:35 -07:00
parent 2486f87010
commit c084f730b3
11 changed files with 774 additions and 7 deletions

View File

@@ -8,6 +8,7 @@ Run (dev): uvicorn server.app:app --reload --port 8000
Run (prod): gunicorn -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 server.app:app
Interactive docs: http://<host>/api/docs
"""
import base64
import os
import re
import uuid
@@ -526,6 +527,25 @@ class TestEmailIn(BaseModel):
to: Optional[str] = None
# CR-007 / D8: the drawing-upload numbers, as settled Aug 18. The ceiling is
# env-overridable so a test can drive the 80% warning and the refusal without
# writing two gigabytes; the DEFAULT is the decision.
FILE_MAX_BYTES = 5 * 1024 * 1024
FILE_PROJECT_CEILING = int(os.getenv("WP_FILE_PROJECT_CEILING", str(2 * 1024 * 1024 * 1024)))
FILE_ALLOWED_MIME_RE = re.compile(r"^(application/pdf|image/[a-z0-9.+-]+)$")
class FileUploadIn(BaseModel):
name: str = ""
mime: str = ""
description: str = ""
data_base64: str = ""
class FileDescIn(BaseModel):
description: str = ""
class StatusIn(BaseModel):
status: str
# CR-014: a rejection (Ready for QA -> In Progress) must say why. The upsert
@@ -1756,6 +1776,14 @@ def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User =
require_assignable(db, new_assignee, body.project_id)
wp.assignee_id = new_assignee
wp.created_by = body.created_by or wp.created_by
# data["files"] is SERVER-owned (CR-007): it mirrors the wp_files table and
# is rewritten by the upload/delete/patch routes. A client that saved before
# an upload landed would otherwise erase the list with its stale copy.
if not is_new:
_stored_files = (old_data or {}).get("files")
if _stored_files is not None:
body.data = dict(body.data or {})
body.data["files"] = _stored_files
wp.data = body.data
if is_new:
_act, _detail = "created", {"status": wp.status}
@@ -2630,6 +2658,139 @@ def archive_wp(wp_id: str, body: ArchiveIn, user: models.User = Depends(auth.get
# ── Audit trail (history) ──────────────────────────────────────────────────────
# ── Drawing uploads (CR-007 / D8) ──────────────────────────────────────────────
def project_storage_used(db: Session, project_id: str) -> int:
return int(db.scalar(
select(func.coalesce(func.sum(models.WpFile.size), 0))
.where(models.WpFile.project_id == project_id)) or 0)
def _wp_files_meta(db: Session, wp_id: str) -> list[dict]:
rows = db.scalars(select(models.WpFile).where(models.WpFile.wp_id == wp_id)
.order_by(models.WpFile.created_at)).all()
return [r.to_dict() for r in rows]
def _sync_wp_files(db: Session, wp: "models.WorkPackage") -> None:
"""Mirror the meta list into data["files"] - server-owned, so exports and the
offline cache read it straight off the package record."""
data = dict(wp.data or {})
data["files"] = _wp_files_meta(db, wp.id)
wp.data = data
@app.get("/api/projects/{project_id}/storage")
def project_storage(project_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
require_project_access(db, user, project_id)
used = project_storage_used(db, project_id)
return {"used": used, "ceiling": FILE_PROJECT_CEILING,
"warn_at": int(FILE_PROJECT_CEILING * 0.8)}
@app.get("/api/wps/{wp_id}/files")
def list_wp_files(wp_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
wp = db.get(models.WorkPackage, wp_id)
if not wp:
raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id)
used = project_storage_used(db, wp.project_id or "")
return {"files": _wp_files_meta(db, wp_id), "used": used,
"ceiling": FILE_PROJECT_CEILING}
@app.post("/api/wps/{wp_id}/files")
def upload_wp_file(wp_id: str, body: FileUploadIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
"""D8, enforced HERE, not only in the browser: 5MB a file, PDF or image, and
a per-project ceiling (2GB by default) that refuses by NAME when reached."""
wp = db.get(models.WorkPackage, wp_id)
if not wp:
raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id)
require_project_writable(db, user, wp.project_id, "Uploading a drawing")
mime = (body.mime or "").strip().lower()
if not FILE_ALLOWED_MIME_RE.match(mime):
raise HTTPException(status_code=400, detail={
"message": "Only PDF and image files are accepted", "mime": mime})
try:
raw = base64.b64decode(body.data_base64 or "", validate=True)
except Exception:
raise HTTPException(status_code=400, detail="File data is not valid base64")
if not raw:
raise HTTPException(status_code=400, detail="The file is empty")
if len(raw) > FILE_MAX_BYTES:
raise HTTPException(status_code=413, detail={
"message": "Files are limited to 5MB each", "size": len(raw),
"limit": FILE_MAX_BYTES})
used = project_storage_used(db, wp.project_id or "")
if used + len(raw) > FILE_PROJECT_CEILING:
raise HTTPException(status_code=413, detail={
"message": f"This project's drawing storage is full ({FILE_PROJECT_CEILING} bytes). "
"Remove an old drawing to make room.",
"used": used, "ceiling": FILE_PROJECT_CEILING})
row = models.WpFile(
id=gen_id("file"), wp_id=wp.id, project_id=wp.project_id or "",
name=(body.name or "drawing")[:300], mime=mime, size=len(raw),
description=(body.description or "")[:500], data=raw,
uploaded_by=user.full_name or user.username)
db.add(row)
db.flush()
_sync_wp_files(db, wp)
log_event(db, user, "file_uploaded", "wp", wp.id, project_id=wp.project_id,
summary=(wp.number or wp.subject or wp.id),
detail={"file": row.name, "size": row.size, "mime": row.mime})
db.commit()
used = project_storage_used(db, wp.project_id or "")
return {"file": row.to_dict(), "used": used, "ceiling": FILE_PROJECT_CEILING,
"warn": used >= int(FILE_PROJECT_CEILING * 0.8)}
@app.get("/api/files/{file_id}")
def get_wp_file(file_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
row = db.get(models.WpFile, file_id)
if not row:
raise HTTPException(status_code=404, detail="File not found")
require_project_access(db, user, row.project_id)
return Response(content=row.data, media_type=row.mime or "application/octet-stream",
headers={"Content-Disposition":
f'inline; filename="{(row.name or "file").replace(chr(34), "")}"',
"Cache-Control": "private, max-age=86400"})
@app.patch("/api/files/{file_id}")
def patch_wp_file(file_id: str, body: FileDescIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
row = db.get(models.WpFile, file_id)
if not row:
raise HTTPException(status_code=404, detail="File not found")
require_project_access(db, user, row.project_id)
require_project_writable(db, user, row.project_id, "Editing a drawing description")
row.description = (body.description or "")[:500]
wp = db.get(models.WorkPackage, row.wp_id)
if wp:
_sync_wp_files(db, wp)
db.commit()
return row.to_dict()
@app.delete("/api/files/{file_id}")
def delete_wp_file(file_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
row = db.get(models.WpFile, file_id)
if not row:
raise HTTPException(status_code=404, detail="File not found")
require_project_access(db, user, row.project_id)
require_project_writable(db, user, row.project_id, "Removing a drawing")
wp = db.get(models.WorkPackage, row.wp_id)
log_event(db, user, "file_deleted", "wp", row.wp_id, project_id=row.project_id,
summary=(wp.number if wp else row.wp_id),
detail={"file": row.name, "size": row.size})
db.delete(row)
db.flush()
if wp:
_sync_wp_files(db, wp)
db.commit()
used = project_storage_used(db, row.project_id)
return {"deleted": file_id, "used": used, "ceiling": FILE_PROJECT_CEILING}
@app.get("/api/audit")
def list_audit(
entity_type: Optional[str] = Query(None),