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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>' +
|
||||
|
||||
19
html/sw.js
19
html/sw.js
@@ -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);
|
||||
|
||||
@@ -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,'"')}" 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,'"')}" 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();
|
||||
|
||||
@@ -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);">
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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
|
||||
|
||||
332
tests/files_check.py
Normal file
332
tests/files_check.py
Normal 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())
|
||||
Reference in New Issue
Block a user