diff --git a/html/wp-creation-styles.css b/html/wp-creation-styles.css
index a457beb..33dde74 100644
--- a/html/wp-creation-styles.css
+++ b/html/wp-creation-styles.css
@@ -581,6 +581,20 @@
.pill-hold.selected { background:var(--red) !important; border-color:var(--red) !important; }
.pill-hold.selected .dot { background:var(--cds-text-on-color) !important; }
+ /* CR-007/D8: the upload strip in Drawings & Attachments. */
+ .file-rules { margin:10px 0 6px; font-size:12px; color:var(--text-muted); }
+ .file-rules .fr-warn { color:var(--accent-amber); font-weight:700; }
+ .file-rules .fr-full { color:var(--red); font-weight:700; }
+ .wp-file-row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin:6px 0; }
+ .wp-file-row input[type="text"] { flex:1 1 240px; }
+ .wp-file-list { display:flex; flex-direction:column; gap:6px; margin:6px 0; }
+ .wp-file-item { display:flex; gap:10px; align-items:center; flex-wrap:wrap;
+ border:1px solid var(--border); border-radius:var(--radius); padding:8px 10px; }
+ .wp-file-item a { color:var(--accent); text-decoration:none; font-weight:600; overflow-wrap:anywhere; }
+ .wp-file-item .wf-size { color:var(--text-muted); font-size:11px; }
+ .wp-file-item input { flex:1 1 200px; font-size:12px; }
+ .wp-file-x { margin-left:auto; }
+
.cstatus { display:inline-flex; border:1px solid var(--border-strong); border-radius:5px; overflow:hidden; }
.cstatus button { border:none; background:var(--surface); color:var(--text-muted); font-family:var(--sans); font-size:11px;
font-weight:600; padding:4px 10px; cursor:pointer; border-right:1px solid var(--border); }
diff --git a/server/alembic/versions/f3a9d2c1e8b7_wp_files_drawing_uploads.py b/server/alembic/versions/f3a9d2c1e8b7_wp_files_drawing_uploads.py
new file mode 100644
index 0000000..b7c8ddf
--- /dev/null
+++ b/server/alembic/versions/f3a9d2c1e8b7_wp_files_drawing_uploads.py
@@ -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')
diff --git a/server/app.py b/server/app.py
index 6cd3463..899a722 100644
--- a/server/app.py
+++ b/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://
/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),
diff --git a/server/models.py b/server/models.py
index 3f51acd..27ed414 100644
--- a/server/models.py
+++ b/server/models.py
@@ -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
diff --git a/tests/files_check.py b/tests/files_check.py
new file mode 100644
index 0000000..8a2b503
--- /dev/null
+++ b/tests/files_check.py
@@ -0,0 +1,332 @@
+#!/usr/bin/env python3
+"""Do drawings travel with the package, and open offline? โ CR-007 / D8, T7.7.
+
+The field wants the specific sheet attached, not a link to a Bluebeam session.
+D8 set the numbers: 5MB a file, PDFs and images, stored in the SAME database
+(a backup that excludes the drawings cannot be restored from), 2GB a project
+with a warning at 80%. Offline caching follows ASSIGNMENT, not project: a
+package assigned to someone else is deliberately not cached.
+
+The ceiling is driven with WP_FILE_PROJECT_CEILING so this probe can hit 80%
+and 100% without writing two gigabytes; the shipped default (asserted here by
+reading the source) is the 2GB decision.
+
+Self-contained: throwaway SQLite, its own uvicorn, headless Edge or Chrome
+with a real service worker and CDP network-offline emulation.
+Exit 0 all passed, 1 a failure, 2 could not run.
+"""
+import base64
+import json
+import os
+import re
+import sys
+import tempfile
+import time
+import urllib.error
+import urllib.request
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+# The ceiling override MUST be in the environment before the server process
+# starts - it is read at import time, which is the point: it is a deploy-time
+# number, not a runtime mutable.
+CEILING = 200_000
+os.environ["WP_FILE_PROJECT_CEILING"] = str(CEILING)
+
+import cdp # noqa: E402
+from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
+from sections_check import set_sop # noqa: E402
+from stepper_check import dismiss_dialogs # noqa: E402
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+PDF_BYTES = b"%PDF-1.4\n1 0 obj<>endobj\ntrailer<<>>\n%%EOF\n" * 20
+PNG_BYTES = base64.b64decode(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk"
+ "+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==")
+
+
+def ascii_(v, n=300):
+ return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
+
+
+def settle(seconds=0.6):
+ time.sleep(seconds)
+
+
+def api(base, path, token, method="GET", body=None, raw=False):
+ req = urllib.request.Request(base + path, method=method)
+ req.add_header("Cookie", "wp_session=" + token)
+ data = None
+ if body is not None:
+ data = json.dumps(body).encode()
+ req.add_header("Content-Type", "application/json")
+ try:
+ with urllib.request.urlopen(req, data, timeout=30) as r:
+ payload = r.read()
+ return r.status, (payload if raw else json.loads(payload.decode() or "null"))
+ except urllib.error.HTTPError as e:
+ try:
+ return e.code, json.loads(e.read().decode() or "null")
+ except Exception:
+ return e.code, None
+
+
+def upload(base, tok, wp_id, name, mime, blob, description=""):
+ return api(base, "/api/wps/%s/files" % wp_id, tok, "POST", {
+ "name": name, "mime": mime, "description": description,
+ "data_base64": base64.b64encode(blob).decode()})
+
+
+def mkwp(base, tok, wp_id, assignee=None):
+ return api(base, "/api/wps", tok, "POST", {
+ "id": wp_id, "project_id": "projA", "number": "F-" + wp_id[-2:],
+ "subject": "drawings host", "status": "In Progress", "assignee_id": assignee,
+ "data": {"constraints": [{"name": "Materials", "status": "cleared", "comment": ""}],
+ "attachments": [{"doc": "E-101", "rev": "2", "link": "https://example.test/e101"}]}})
+
+
+def wait_for(fn, timeout=15.0):
+ end = time.time() + timeout
+ while time.time() < end:
+ try:
+ if fn():
+ return True
+ except Exception:
+ pass
+ time.sleep(0.4)
+ return False
+
+
+def main():
+ exe = cdp.find_browser()
+ if not exe:
+ print("no headless-capable browser found; set WP_BROWSER.")
+ return 2
+
+ tmpdir = tempfile.mkdtemp(prefix="wpsuite-files-")
+ db_path = os.path.join(tmpdir, "check.db")
+ server = None
+ browser = None
+ try:
+ tok = seed(db_path)
+ set_sop(db_path, {})
+ port = cdp.free_port()
+ base = "http://127.0.0.1:%d" % port
+ server = start_server(port, db_path)
+ root = tok["root"]
+
+ # โโ 1. upload and retrieval โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ print("\n1. upload, retrieve, describe, delete")
+ mkwp(base, root, "wpF1", assignee="user_root")
+ code, out = upload(base, root, "wpF1", "tray-L3.pdf", "application/pdf",
+ PDF_BYTES, "Tray section, Level 3 east only")
+ chk("a PDF uploads", code == 200, ascii_((code, out)))
+ pdf_id = (out or {}).get("file", {}).get("id", "")
+ code, blob = api(base, "/api/files/" + pdf_id, root, raw=True)
+ chk("...and comes back byte-for-byte", code == 200 and blob == PDF_BYTES,
+ (code, len(blob or b"")))
+ code, out = upload(base, root, "wpF1", "sector-p.png", "image/png", PNG_BYTES,
+ "Highlighted sector P")
+ chk("an image uploads", code == 200, code)
+ png_id = (out or {}).get("file", {}).get("id", "")
+ code, blob = api(base, "/api/files/" + png_id, root, raw=True)
+ chk("...and comes back byte-for-byte", code == 200 and blob == PNG_BYTES, code)
+
+ _, wp = api(base, "/api/wps/wpF1", root)
+ files = (wp.get("data") or {}).get("files") or []
+ chk("the package record mirrors the file list, descriptions included",
+ len(files) == 2 and files[0].get("description") == "Tray section, Level 3 east only",
+ ascii_(files))
+ chk("the link attachments are still on the package, beside the uploads",
+ (wp.get("data") or {}).get("attachments", [{}])[0].get("link")
+ == "https://example.test/e101")
+
+ code, _ = api(base, "/api/files/" + png_id, root, "PATCH",
+ {"description": "Sector P, north wall only"})
+ _, listing = api(base, "/api/wps/wpF1/files", root)
+ chk("a description edit persists",
+ code == 200 and any(f.get("description") == "Sector P, north wall only"
+ for f in listing.get("files", [])), ascii_(listing))
+
+ # The upsert cannot clobber the server-owned list with a stale client copy.
+ api(base, "/api/wps", root, "POST", {
+ "id": "wpF1", "project_id": "projA", "number": wp["number"],
+ "subject": wp["subject"], "status": wp["status"],
+ "data": {k: v for k, v in (wp.get("data") or {}).items() if k != "files"}})
+ _, wp2 = api(base, "/api/wps/wpF1", root)
+ chk("a client save WITHOUT the file list does not erase it (server-owned key)",
+ len((wp2.get("data") or {}).get("files") or []) == 2,
+ ascii_((wp2.get("data") or {}).get("files")))
+
+ # โโ 2. refusals, server-side โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ print("\n2. the server refuses what the browser refuses")
+ code, out = upload(base, root, "wpF1", "notes.txt", "text/plain", b"hello")
+ chk("a type outside PDF/image is refused, naming what IS accepted",
+ code == 400 and "PDF and image" in str(out), ascii_((code, out)))
+ big = b"x" * (5 * 1024 * 1024 + 1)
+ code, out = upload(base, root, "wpF1", "big.pdf", "application/pdf", big)
+ chk("a file over 5MB is refused, naming the limit",
+ code == 413 and "5MB" in str(out), ascii_((code, out)))
+ code, _ = upload(base, tok["bob"], "wpF1", "x.pdf", "application/pdf", PDF_BYTES)
+ chk("someone outside the project cannot upload", code == 403, code)
+ code, _ = api(base, "/api/files/" + pdf_id, tok["bob"])
+ chk("...or fetch", code == 403, code)
+
+ # โโ 3. the ceiling โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ print("\n3. the 2GB ceiling (driven at %d bytes)" % CEILING)
+ src = open(os.path.join(ROOT, "server", "app.py"), encoding="utf-8").read()
+ chk("the shipped default IS the decision: 2GB, in the source",
+ "2 * 1024 * 1024 * 1024" in src)
+ filler = b"f" * 165_000 # past 80% of the 200KB ceiling
+ code, out = upload(base, root, "wpF1", "fill.pdf", "application/pdf", filler)
+ chk("a large upload under the ceiling lands", code == 200, ascii_((code, out)))
+ chk("...and the response flags the 80% warning",
+ (out or {}).get("warn") is True, ascii_(out))
+ used_before = (out or {}).get("used", 0)
+ _, st = api(base, "/api/projects/projA/storage", root)
+ chk("the storage endpoint reports the running total",
+ st.get("used", 0) == used_before and st.get("ceiling") == CEILING, ascii_(st))
+ code, out = upload(base, root, "wpF1", "over.pdf", "application/pdf", b"y" * 100_000)
+ chk("at the ceiling the upload is refused, naming it",
+ code == 413 and str(CEILING) in str(out) and "full" in str(out),
+ ascii_((code, out)))
+ code, out = api(base, "/api/files/" + pdf_id, root, "DELETE")
+ chk("deleting a drawing frees its bytes from the total",
+ code == 200 and out.get("used", 10**9) < used_before, ascii_(out))
+
+ # a second package, assigned to someone ELSE, with its own drawing - the
+ # offline check needs a file that must NOT be cached.
+ mkwp(base, root, "wpF2", assignee="user_sue")
+ code, out = upload(base, root, "wpF2", "other.png", "image/png", PNG_BYTES,
+ "someone else's sheet")
+ other_id = (out or {}).get("file", {}).get("id", "")
+
+ # โโ 4. the creator: limits first, meter always, refusal costs nothing โ
+ print("\n4. the creator at 1440px")
+ browser = cdp.Browser(exe)
+ page = browser.page()
+ page.clear_cookies()
+ page.set_cookie("wp_session", root)
+ page.viewport(1440, 900)
+ page.goto(base + "/wp-creation-index.html?project=projA&wp=wpF1")
+ dismiss_dialogs(page)
+ chk("the creator boots on the package",
+ wait_for(lambda: page.eval("!!window.wpCreatorReady")), )
+ settle(1.6)
+ page.eval("window.prompt=()=>null; window.alert=()=>{}; window.confirm=()=>true;")
+ rules = page.eval("(document.getElementById('file-rules')||{textContent:''}).textContent")
+ chk("the limits are stated BEFORE upload: 5MB and PDF/image, in the card",
+ "5MB" in rules and "PDF" in rules, ascii_(rules))
+ chk("the running project total is visible where uploads happen",
+ "Project storage:" in rules, ascii_(rules))
+ chk("the uploaded drawings are listed with their descriptions",
+ page.eval("document.querySelectorAll('#wp-file-list .wp-file-item').length") >= 1)
+
+ page.eval("""window.__fetchCalls=[]; window.__origFetch=window.fetch;
+ window.fetch=function(u,o){ window.__fetchCalls.push(String(u)); return window.__origFetch(u,o); };""")
+ page.eval("""wpFileUpload({target:{files:[
+ new File([new Uint8Array(6*1024*1024)], 'big.pdf', {type:'application/pdf'})], value:''}})""")
+ settle(0.8)
+ toast_txt = page.eval("(document.getElementById('toast')||{textContent:''}).textContent")
+ chk("an oversize file is refused BEFORE upload, naming the limit",
+ "5MB" in toast_txt, ascii_(toast_txt))
+ chk("...and no upload request ever left the browser",
+ page.eval("!window.__fetchCalls.some(u=>u.includes('/files'))"))
+ page.eval("""wpFileUpload({target:{files:[
+ new File(['zzz'], 'macro.docx', {type:'application/vnd.ms-word'})], value:''}})""")
+ settle(0.8)
+ toast_txt = page.eval("(document.getElementById('toast')||{textContent:''}).textContent")
+ chk("a type outside PDF/image is refused before upload, naming the accepted types",
+ "PDF and image" in toast_txt, ascii_(toast_txt))
+
+ page.eval("""wpFileUpload({target:{files:[
+ new File([new Uint8Array([137,80,78,71])], 'site.png', {type:'image/png'})], value:''}})""")
+ chk("a real upload through the form lands and appears in the list",
+ wait_for(lambda: page.eval(
+ "[...document.querySelectorAll('#wp-file-list a')].some(a=>a.textContent.includes('site.png'))")))
+
+ page.eval("renderPackage(collectPackage())")
+ settle(0.8)
+ doc = page.eval("(document.getElementById('pkg-doc')||{innerHTML:''}).innerHTML")
+ chk("the export carries the uploads: name, description, and the image inline",
+ "sector-p.png" in doc and "Sector P, north wall only" in doc
+ and "/api/files/" in doc and "
c.keys()).then(ks=>ks.some(k=>k.url.includes('%s')))" % png_id), 20))
+ chk("a package assigned to someone ELSE is not cached (D8)",
+ page.eval("caches.open('wp-suite-drawings-v1').then(c=>c.keys())"
+ ".then(ks=>!ks.some(k=>k.url.includes('%s')))" % other_id))
+
+ # the drawing row itself, on the phone-size detail
+ page.eval("openWP('wpF1')")
+ settle(0.8)
+ row = json.loads(page.eval("""JSON.stringify((() => {
+ const a = document.querySelector('.fld-drawing');
+ if (!a) return null;
+ const r = a.getBoundingClientRect();
+ return {text: a.textContent, h: r.height, w: r.width, within: r.right <= 390};
+ })())"""))
+ chk("390px: the drawing row renders on the package, description included",
+ row is not None and "Sector P" in (row["text"] or ""), ascii_(row))
+ chk("390px: it is a 44px touch target that stays inside the screen",
+ row and row["h"] >= 44 and row["within"], ascii_(row))
+
+ js_errors = [e for e in page.js_errors()]
+ chk("no JavaScript errors anywhere in this run", not js_errors,
+ ascii_(js_errors[:2]))
+
+ # โโ 6. the network actually goes away โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ # CDP's emulateNetworkConditions only throttles the PAGE's session; the
+ # service worker fetches on its own target and sails straight past it -
+ # which made the first version of this check pass for the wrong reason.
+ # Killing the server is offline nobody can argue with.
+ print(chr(10) + "6. the server is gone")
+ server.terminate()
+ server = None
+ settle(1.5)
+ chk("offline: my assigned package's drawing still opens (from the SW cache)",
+ page.eval("fetch('/api/files/%s').then(r=>r.ok).catch(()=>false)" % png_id) is True)
+ chk("offline: the other package's drawing does not (assignment-scoped, D8)",
+ page.eval("fetch('/api/files/%s').then(r=>r.ok).catch(()=>false)" % other_id) is False)
+
+ finally:
+ if browser is not None:
+ try:
+ browser.close()
+ except Exception:
+ pass
+ if server is not None:
+ try:
+ server.terminate()
+ except Exception:
+ pass
+
+ print("\n" + "-" * 54)
+ print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
+ for f in _FAIL:
+ print(" - " + f)
+ return 1 if _FAIL else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())