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

@@ -0,0 +1,51 @@
"""drawing uploads stored with the package (CR-007 / D8)
The field wants the specific PDF attached, not a link to a Bluebeam session: a
general foreman opens the package and sees exactly the sheet relevant to their
scope, offline. The bytes live in this table - IN the same database as
everything else, settled Aug 18: splitting files out was rejected because a
backup that excludes the drawings is a backup you cannot restore from. The cost
of that decision is bounded by the D8 numbers, enforced in the API: 5MB a file,
PDFs and images only, 2GB per project with a warning at 80%.
Additive only: a new table, no change to any existing one, so nothing to
backfill and nothing to migrate.
Revision ID: f3a9d2c1e8b7
Revises: e2a4c7d91b30
Create Date: 2026-08-19 11:20:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f3a9d2c1e8b7'
down_revision = 'e2a4c7d91b30'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'wp_files',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('wp_id', sa.String(length=40), nullable=False),
sa.Column('project_id', sa.String(length=40), nullable=False),
sa.Column('name', sa.String(length=300), nullable=False, server_default=''),
sa.Column('mime', sa.String(length=100), nullable=False, server_default=''),
sa.Column('size', sa.Integer(), nullable=False, server_default='0'),
sa.Column('description', sa.String(length=500), nullable=False, server_default=''),
sa.Column('data', sa.LargeBinary(), nullable=False),
sa.Column('uploaded_by', sa.String(length=120), nullable=False, server_default=''),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_wp_files_wp_id', 'wp_files', ['wp_id'])
op.create_index('ix_wp_files_project_id', 'wp_files', ['project_id'])
def downgrade() -> None:
op.drop_index('ix_wp_files_project_id', table_name='wp_files')
op.drop_index('ix_wp_files_wp_id', table_name='wp_files')
op.drop_table('wp_files')

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),

View File

@@ -27,7 +27,7 @@ together.
"""
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, Text, JSON, UniqueConstraint
from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, Text, JSON, LargeBinary, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from .db import Base
@@ -347,6 +347,37 @@ class AppSetting(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
class WpFile(Base):
"""CR-007 / D8: a drawing uploaded onto a work package. The BYTES live here,
in the same database as everything else - settled Aug 18: a backup that
excludes the drawings is a backup you cannot restore from. The limits are
the D8 numbers: 5MB a file, PDFs and images, 2GB per project (80% warning).
A meta copy (no bytes) is mirrored into the package's data["files"] by the
server so the list is exportable and readable offline; that key is
server-owned and survives client upserts."""
__tablename__ = "wp_files"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
wp_id: Mapped[str] = mapped_column(String(40), index=True)
project_id: Mapped[str] = mapped_column(String(40), index=True)
name: Mapped[str] = mapped_column(String(300), default="")
mime: Mapped[str] = mapped_column(String(100), default="")
size: Mapped[int] = mapped_column(Integer, default=0)
description: Mapped[str] = mapped_column(String(500), default="")
data: Mapped[bytes] = mapped_column(LargeBinary, default=b"")
uploaded_by: Mapped[str] = mapped_column(String(120), default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
def to_dict(self) -> dict:
# Meta only - the bytes go through GET /api/files/{id}, never through JSON.
return {
"id": self.id, "wp_id": self.wp_id, "project_id": self.project_id,
"name": self.name, "mime": self.mime, "size": self.size,
"description": self.description, "uploaded_by": self.uploaded_by,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
class Notification(Base):
"""Outbox for user notifications (an in-app record + an optional email). A row
is written when something notable happens (e.g. a WP assignment); the email