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:
@@ -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')
|
||||
161
server/app.py
161
server/app.py
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user