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

@@ -281,6 +281,7 @@ python tests/hold_check.py # CR-015/A1/D4 - the hold clears, gates hol
python tests/warning_check.py # A2 - one warning, a badge from anywhere 17 checks
python tests/triage_check.py # A6 - the sidebar answers the stand-up 16 checks
python tests/qa_gate_check.py # CR-014/D2/D9/D10 - QA gate + capture sink 40 checks
python tests/files_check.py # CR-007/D8 - drawings upload + real offline 36 checks
```
**Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live

View File

@@ -64,6 +64,11 @@
.log-item img { max-width: 160px; max-height: 120px; margin-top: 6px; display: block; border: 1px solid var(--cds-border-subtle); }
.fld-toast { position: fixed; bottom: 76px; left: 50%; transform: translateX(-50%); background: var(--cds-ui-05); color: var(--cds-text-on-color); padding: 12px 20px; font-size: 14px; opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 50; }
.fld-toast.show { opacity: 1; }
/* CR-007: drawings open from the card, offline once prefetched. 44px rows. */
.fld-drawing { display:block; padding:12px 10px; min-height:44px; box-sizing:border-box;
border:1px solid var(--cds-border-subtle-01); border-radius:6px; margin-bottom:8px;
color: var(--cds-link-primary); text-decoration:none; font-size:14px; }
.fld-drawing:active { background: var(--cds-layer-hover-01); }
</style>
</head>
<body>

View File

@@ -65,9 +65,23 @@ function loadWPs() {
ProjectData.pullProject(PID).then(function () {
WPS = activePkgs(readCache());
if (!curId) renderList(); else renderDetail();
prefetchMyDrawings();
}).catch(function () {});
}
}
// CR-007/D8: warm the drawing cache for MY packages while there is a network.
// Deliberately only the requesting user's assignments - the decision was that
// offline coverage follows assignment, not the whole project's 2GB.
function prefetchMyDrawings() {
var myId = (window.WP_USER || {}).id;
if (!myId || !('serviceWorker' in navigator)) return;
WPS.filter(function (p) { return p.assigneeId === myId; }).forEach(function (p) {
((p.files) || []).forEach(function (f) {
if (f && f.id) fetch('/api/files/' + f.id).catch(function () {});
});
});
}
function showNoProject() {
var s = document.getElementById('screen-list');
if (s) s.innerHTML = '<div class="fld-empty">No project selected.<br><a href="index.html">Pick a project on the home page</a>, then reopen the field view.</div>';
@@ -132,6 +146,11 @@ function renderDetail() {
'<div class="fld-sub">' + esc(p.subject || '') + (p.type ? ' · ' + esc(p.type) : '') + '</div>' +
'<div class="fld-sec"><h3>Status</h3><div class="st-grid">' + stBtns + '</div></div>' +
'<div class="fld-sec"><h3>Constraints — ' + openCount(p) + ' open</h3>' + cxRows + '</div>' +
(((p.files) || []).length ? '<div class="fld-sec"><h3>Drawings</h3>' +
p.files.map(function (f) {
return '<a class="fld-drawing" href="/api/files/' + esc(f.id) + '" target="_blank" rel="noopener">' +
'📄 ' + esc(f.name || 'drawing') + (f.description ? ' — ' + esc(f.description) : '') + '</a>';
}).join('') + '</div>' : '') +
'<div class="fld-sec"><h3>Add field update</h3>' +
'<textarea class="fld-note" id="fld-note" placeholder="What happened on site? (progress, blockers, notes)" oninput="draftNote=this.value">' + esc(draftNote) + '</textarea>' +
'<div class="fld-photo-row"><label class="fld-btn">📷 Add photo<input type="file" accept="image/*" capture="environment" style="display:none" onchange="onPhoto(event)"></label>' +

View File

@@ -15,6 +15,11 @@
// Bumped when the shell file list changes, so clients fetch the new assets
// instead of serving a half-old shell from the previous cache.
const CACHE = 'wp-suite-shell-v6';
// CR-007/D8: uploaded drawings, cached at first fetch so an assigned package's
// sheets open with no network. The CLIENT decides what gets fetched (field.js
// prefetches only the requesting user's assigned packages); this worker just
// keeps whatever came through. Never precached - a fresh sign-in starts empty.
const DRAWINGS = 'wp-suite-drawings-v1';
const SHELL = [
'/', '/index.html', '/work-package-suite.html', '/wp-creation-index.html',
'/field.html', '/login.html', '/admin.html', '/users.html',
@@ -43,7 +48,7 @@ self.addEventListener('install', (e) => {
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE && k !== DRAWINGS).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
@@ -65,6 +70,18 @@ self.addEventListener('fetch', (e) => {
if (req.method !== 'GET') return; // outbox owns writes
const url = new URL(req.url);
if (url.origin !== self.location.origin) return; // third-party: default
// Drawing bytes are immutable once uploaded (edits replace the row id), so
// cache-first is safe and is what makes them open offline (CR-007/D8).
if (url.pathname.startsWith('/api/files/')) {
e.respondWith(
caches.open(DRAWINGS).then((c) => c.match(req).then((hit) => hit ||
fetch(req).then((res) => {
if (res && res.ok) c.put(req, res.clone());
return res;
})))
);
return;
}
if (url.pathname.startsWith('/api/')) return; // never cache the API
const isCode = CODE_RE.test(url.pathname);

View File

@@ -66,6 +66,7 @@ let SOP=null, editingId=null, numberDirty=false;
let pkgKind='iwp'; // 'iwp' (install) | 'ewp' (BIM) — per-package, only relevant when SOP.bimEnabled
let activeProjectId=''; // set at boot from ?project=<id>; stamped onto saved WPs for the API
let pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[], pkgAssets=[], pkgQaRejections=[];
let pkgFiles=[]; // CR-007: uploaded drawing METAS - bytes live on the server, data['files'] is server-owned
let pkgOverrides={}; // {fieldId: reason} for SOP-locked fields that were edited
let numberDims={}; // {Sector:'', Discipline:''} dimensions that build the WP number (comment 3)
let pkgDisciplines=[]; // disciplines this WP covers (from SOP governance.disciplines)
@@ -868,6 +869,116 @@ function buildAttach(){
tb.appendChild(tr); });
}
function addAttach(){ pkgAttach.push({doc:'',rev:'',link:''}); buildAttach(); }
// ── CR-007 / D8: drawing uploads ─────────────────────────────────────────────
// Files attach to the SAVED record (they are rows, not form state), so the form
// around an upload is never at risk: a refused or failed upload changes nothing
// but the toast. Both limits are ALSO enforced by the server - these checks are
// the courtesy of refusing before the bytes travel.
function wpFileSize(n){
if(n>=1024*1024) return (n/1024/1024).toFixed(1)+'MB';
if(n>=1024) return Math.round(n/1024)+'KB';
return n+'B';
}
async function wpFileUpload(ev){
const inp=ev.target; const f=inp.files&&inp.files[0]; inp.value='';
if(!f) return;
if(!/^(application\/pdf|image\/)/.test(f.type||'')){
toast('Only PDF and image files are accepted — "'+(f.type||f.name.split('.').pop()||'that')+'" is neither.');
return;
}
if(f.size>5*1024*1024){
toast('Files are limited to 5MB each — this one is '+wpFileSize(f.size)+'.');
return;
}
if(!editingId){
toast('Save the package first — uploads attach to the saved record.');
return;
}
const desc=(document.getElementById('wp-file-desc')||{value:''}).value.trim();
let b64='';
try{
b64=await new Promise((res,rej)=>{ const r=new FileReader();
r.onload=()=>res(String(r.result).split(',')[1]||''); r.onerror=rej; r.readAsDataURL(f); });
}catch(e){ toast('Could not read that file — the form is untouched.'); return; }
let resp, out={};
try{
resp=await fetch('/api/wps/'+encodeURIComponent(editingId)+'/files',{
method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({name:f.name, mime:f.type, description:desc, data_base64:b64})});
out=await resp.json().catch(()=>({}));
}catch(e){
toast('Upload failed — the network dropped. Nothing else was lost; try again.');
return;
}
if(!resp.ok){
const d=out&&out.detail;
const msg=(d&&d.message)||(typeof d==='string'?d:'Upload failed ('+resp.status+')');
toast(msg);
return;
}
pkgFiles.push(out.file);
wpFilesMirror();
const de=document.getElementById('wp-file-desc'); if(de) de.value='';
wpFilesRender(); wpFileUsageSet(out.used, out.ceiling);
toast('Uploaded '+f.name+'.'); track('file_uploaded');
}
// Keep the local saved-package copy in step so exports and the offline cache
// see the list without another fetch. The server copy is authoritative.
function wpFilesMirror(){
const ix=savedPackages.findIndex(x=>x.id===editingId);
if(ix>=0){ savedPackages[ix].files=pkgFiles.map(x=>({...x})); saveStore(); }
}
function wpFilesRender(){
const box=document.getElementById('wp-file-list'); if(!box) return;
box.innerHTML=pkgFiles.map(f=>`<div class="wp-file-item">
<a href="/api/files/${encodeURIComponent(f.id)}" target="_blank" rel="noopener">${f.mime==='application/pdf'?'📄':'🖼'} ${esc(f.name||'drawing')}</a>
<span class="wf-size">${wpFileSize(f.size||0)}</span>
<input type="text" value="${(f.description||'').replace(/"/g,'&quot;')}" placeholder="focus area, e.g. Tray section, Level 3 east only"
aria-label="Description of ${esc(f.name||'drawing')}" onchange="wpFileDescSave('${f.id}', this.value)">
<button type="button" class="btn btn-ghost wp-file-x" onclick="wpFileDelete('${f.id}')">Remove</button>
</div>`).join('');
}
async function wpFileDescSave(id, v){
try{
const r=await fetch('/api/files/'+encodeURIComponent(id),{method:'PATCH',
headers:{'Content-Type':'application/json'}, body:JSON.stringify({description:v})});
if(!r.ok) throw 0;
const f=pkgFiles.find(x=>x.id===id); if(f) f.description=v;
wpFilesMirror();
}catch(e){ toast('Could not save the description — it is shown but not saved yet.'); }
}
async function wpFileDelete(id){
if(!confirm('Remove this drawing from the package?')) return;
try{
const r=await fetch('/api/files/'+encodeURIComponent(id),{method:'DELETE'});
if(!r.ok) throw 0;
const out=await r.json();
pkgFiles=pkgFiles.filter(f=>f.id!==id);
wpFilesMirror(); wpFilesRender(); wpFileUsageSet(out.used, out.ceiling);
toast('Drawing removed.');
}catch(e){ toast('Could not remove the drawing — try again.'); }
}
// The running total, on the same line as the rules (D8: warn from 80%, refuse at
// the ceiling - the refusal itself comes from the server and names the number).
function wpFileUsageSet(used, ceiling){
const el=document.getElementById('file-usage'); if(el==null) return;
if(typeof used!=='number' || !ceiling){ el.textContent=''; return; }
const pct=used/ceiling;
const txt=`Project storage: ${wpFileSize(used)} of ${wpFileSize(ceiling)} used.`;
el.innerHTML = pct>=1 ? `<span class="fr-full">${txt} Full — remove a drawing to make room.</span>`
: pct>=0.8 ? `<span class="fr-warn">${txt} Approaching the ceiling.</span>`
: esc(txt);
}
async function wpFilesRefreshUsage(){
if(!activeProjectId) return;
try{
const r=await fetch('/api/projects/'+encodeURIComponent(activeProjectId)+'/storage');
if(!r.ok) return;
const out=await r.json();
wpFileUsageSet(out.used, out.ceiling);
}catch(e){}
}
function removeAttach(i){ pkgAttach.splice(i,1); if(!pkgAttach.length)pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); }
// ── ADD FILES FROM SOP FOLDER (no-auth interim) ──────────────────────────────
@@ -1292,6 +1403,7 @@ function collectPackage(){
signoffs:pkgSignoffs.map(s=>({role:s.role,name:s.name,date:s.date,signed:s.signed,dateReason:s.dateReason||''})),
holds:pkgHolds.map(h=>({...h})),
qaRejections:pkgQaRejections.map(x=>({...x})),
files:pkgFiles.map(x=>({...x})), // metas only; the server re-asserts this key on save
actualHrs:gv('wp_actual_hrs'), installedQty:gv('wp_installed_qty'), redlines:gv('wp_redlines'), lessons:gv('wp_lessons'),
kind: pkgKind, // 'iwp' (install) | 'ewp' (BIM) — drives which fields/types/gates apply
// AWP traceability: which BIM/model package(s) enabled this install package.
@@ -1423,10 +1535,18 @@ function renderPackage(pkg){
pkg.materials.forEach(m=>t+=`<tr><td>${cell(m.qty)}</td><td>${cell(m.unit)}</td><td>${cell(m.desc)}</td>${showDisc?`<td>${cell(m.discipline)}</td>`:''}</tr>`);
add('materials', 'Material List', t+`</tbody></table>`); }
if(pkg.attachments&&pkg.attachments.length){
let t=`<table><thead><tr><th>Document</th><th style="width:60px">Rev</th><th>Link / Note</th></tr></thead><tbody>`;
pkg.attachments.forEach(a=>t+=`<tr><td>${cell(a.doc)}</td><td>${cell(a.rev)}</td><td>${a.link?linkify(a.link):ns()}</td></tr>`);
add('drawings', 'Drawings & Attachments', t+`</tbody></table>`); }
if((pkg.attachments&&pkg.attachments.length)||(pkg.files&&pkg.files.length)){
let t=`<table><thead><tr><th>Document</th><th style="width:60px">Rev</th><th>Link / Note / Focus area</th></tr></thead><tbody>`;
(pkg.attachments||[]).forEach(a=>t+=`<tr><td>${cell(a.doc)}</td><td>${cell(a.rev)}</td><td>${a.link?linkify(a.link):ns()}</td></tr>`);
// CR-007: uploaded drawings print WITH the package - the row for every file,
// the image itself inline (it is the sheet the crew needs), PDFs as links.
(pkg.files||[]).forEach(f=>t+=`<tr><td><a href="/api/files/${encodeURIComponent(f.id)}" target="_blank" rel="noopener">${esc(f.name||'drawing')}</a> <span style="font-size:10px;color:var(--text-dim)">[uploaded, ${wpFileSize(f.size||0)}]</span></td><td>${ns()}</td><td>${cell(f.description)}</td></tr>`);
t+=`</tbody></table>`;
(pkg.files||[]).filter(f=>/^image\//.test(f.mime||'')).forEach(f=>{
t+=`<figure style="margin:10px 0"><img src="/api/files/${encodeURIComponent(f.id)}" alt="${(f.name||'').replace(/"/g,'&quot;')}" style="max-width:100%">`
+(f.description?`<figcaption style="font-size:11px;color:var(--text-muted)">${esc(f.description)}</figcaption>`:'')+`</figure>`;
});
add('drawings', 'Drawings & Attachments', t); }
add('kitting', 'Kitting & MIMO', `<table><tbody>
<tr><th style="width:200px">Kitting Status</th><td>${cell(pkg.kitStatus)}</td></tr>
@@ -2435,6 +2555,7 @@ function loadPackageIntoForm(p){
pkgSignoffs=(p.signoffs||[]).map(s=>({...s, fromSOP:!!s.name})); if(!pkgSignoffs.length) buildSignoffs(); else renderSignoffRows();
pkgHolds=(p.holds||[]).map(h=>({...h}));
pkgQaRejections=(p.qaRejections||[]).map(x=>({...x}));
pkgFiles=(p.files||[]).map(x=>({...x})); wpFilesRender(); wpFilesRefreshUsage();
updateNumber(); updateReleaseBanner(); prevStatus=p.status||'Draft'; showForm(); wpFormPopulated();
}
function renderConstraintRows(){ const tmp=pkgConstraints; pkgConstraints=[]; buildConstraints();
@@ -2497,7 +2618,7 @@ function newPackage(){
pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach();
pkgWorkSteps=['']; buildWorkSteps();
pkgConstraints=[]; buildConstraints(); pkgSignoffs=[]; buildSignoffs();
pkgHolds=[]; pkgQaRejections=[]; pkgOverrides={};
pkgHolds=[]; pkgQaRejections=[]; pkgFiles=[]; wpFilesRender(); pkgOverrides={};
set_quality_from_sop(); lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
prevStatus='Draft';
updateNumber(); updateReleaseBanner(); defaultOwnerToMe(); showForm(); wpFormPopulated(); renderWpNav(); track('new_package');
@@ -3269,6 +3390,7 @@ function bootData(){
setRadio('status','Draft');
loadMembers();
loadLocations(); // CR-004: the option lists come from the server
wpFilesRefreshUsage(); // CR-007: the storage meter is live before the first upload
initWpNavDrawer();
renderSavedList();
positionSectionNav();

View File

@@ -355,6 +355,20 @@
<div class="sub-heading">Drawings & Attachments</div>
<div id="sop-ref-links" class="sop-ref-links"></div>
<div class="table-wrap"><table><thead><tr><th>Document / Drawing</th><th style="width:90px">Rev</th><th>Link / Note</th><th style="width:44px"></th></tr></thead><tbody id="attach-body"></tbody></table></div>
<!-- CR-007/D8: uploads live beside the links, never instead of them. The
limits are stated HERE, before anyone picks a file, and the running
project total is on the same line (warning style from 80%). -->
<div class="file-rules" id="file-rules">
Uploads: <strong>PDF or image, up to 5MB a file.</strong>
<span id="file-usage" aria-live="polite"></span>
</div>
<div id="wp-file-list" class="wp-file-list"></div>
<div class="wp-file-row">
<label class="add-btn wp-file-pick">⇪ Upload drawing
<input type="file" id="wp-file-input" accept="application/pdf,image/png,image/jpeg,image/gif,image/webp" style="display:none" onchange="wpFileUpload(event)">
</label>
<input type="text" id="wp-file-desc" placeholder="focus area, e.g. Tray section, Level 3 east only" aria-label="Description for the next upload">
</div>
<button class="add-btn" onclick="addAttach()">+ Add document</button>
<button class="add-btn" onclick="toggleSopFilePanel()">+ Add files from SOP folder</button>
<div id="sop-file-panel" style="display:none; margin-top:0.75rem; padding:0.75rem; border:1px dashed var(--border); border-radius:6px; background:var(--bg);">

View File

@@ -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); }

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

332
tests/files_check.py Normal file
View File

@@ -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<</Type/Catalog>>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 "<img" in doc, ascii_(doc, 200))
chk("...and still carries the plain link attachments",
"E-101" in doc and "example.test/e101" in doc)
page.close()
# ── 5. the field view at 390px, then the network goes away ───────────
print("\n5. offline on the tablet")
page = browser.page()
page.clear_cookies()
page.set_cookie("wp_session", root)
page.viewport(390, 844, mobile=True)
page.goto(base + "/field.html?project=projA")
dismiss_dialogs(page)
settle(2.0)
chk("the service worker takes the page",
wait_for(lambda: page.eval("!!(navigator.serviceWorker&&navigator.serviceWorker.controller)"), 20))
# the prefetch runs after the pull; give it a beat, then check the cache
chk("the drawings of MY assigned package are cached",
wait_for(lambda: page.eval(
"caches.open('wp-suite-drawings-v1').then(c=>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())