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/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/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/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
|
**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); }
|
.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 { 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; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -65,9 +65,23 @@ function loadWPs() {
|
|||||||
ProjectData.pullProject(PID).then(function () {
|
ProjectData.pullProject(PID).then(function () {
|
||||||
WPS = activePkgs(readCache());
|
WPS = activePkgs(readCache());
|
||||||
if (!curId) renderList(); else renderDetail();
|
if (!curId) renderList(); else renderDetail();
|
||||||
|
prefetchMyDrawings();
|
||||||
}).catch(function () {});
|
}).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() {
|
function showNoProject() {
|
||||||
var s = document.getElementById('screen-list');
|
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>';
|
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-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>Status</h3><div class="st-grid">' + stBtns + '</div></div>' +
|
||||||
'<div class="fld-sec"><h3>Constraints — ' + openCount(p) + ' open</h3>' + cxRows + '</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>' +
|
'<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>' +
|
'<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>' +
|
'<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
|
// Bumped when the shell file list changes, so clients fetch the new assets
|
||||||
// instead of serving a half-old shell from the previous cache.
|
// instead of serving a half-old shell from the previous cache.
|
||||||
const CACHE = 'wp-suite-shell-v6';
|
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 = [
|
const SHELL = [
|
||||||
'/', '/index.html', '/work-package-suite.html', '/wp-creation-index.html',
|
'/', '/index.html', '/work-package-suite.html', '/wp-creation-index.html',
|
||||||
'/field.html', '/login.html', '/admin.html', '/users.html',
|
'/field.html', '/login.html', '/admin.html', '/users.html',
|
||||||
@@ -43,7 +48,7 @@ self.addEventListener('install', (e) => {
|
|||||||
self.addEventListener('activate', (e) => {
|
self.addEventListener('activate', (e) => {
|
||||||
e.waitUntil(
|
e.waitUntil(
|
||||||
caches.keys()
|
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())
|
.then(() => self.clients.claim())
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -65,6 +70,18 @@ self.addEventListener('fetch', (e) => {
|
|||||||
if (req.method !== 'GET') return; // outbox owns writes
|
if (req.method !== 'GET') return; // outbox owns writes
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
if (url.origin !== self.location.origin) return; // third-party: default
|
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
|
if (url.pathname.startsWith('/api/')) return; // never cache the API
|
||||||
|
|
||||||
const isCode = CODE_RE.test(url.pathname);
|
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 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 activeProjectId=''; // set at boot from ?project=<id>; stamped onto saved WPs for the API
|
||||||
let pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[], pkgAssets=[], pkgQaRejections=[];
|
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 pkgOverrides={}; // {fieldId: reason} for SOP-locked fields that were edited
|
||||||
let numberDims={}; // {Sector:'', Discipline:''} dimensions that build the WP number (comment 3)
|
let numberDims={}; // {Sector:'', Discipline:''} dimensions that build the WP number (comment 3)
|
||||||
let pkgDisciplines=[]; // disciplines this WP covers (from SOP governance.disciplines)
|
let pkgDisciplines=[]; // disciplines this WP covers (from SOP governance.disciplines)
|
||||||
@@ -868,6 +869,116 @@ function buildAttach(){
|
|||||||
tb.appendChild(tr); });
|
tb.appendChild(tr); });
|
||||||
}
|
}
|
||||||
function addAttach(){ pkgAttach.push({doc:'',rev:'',link:''}); buildAttach(); }
|
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(); }
|
function removeAttach(i){ pkgAttach.splice(i,1); if(!pkgAttach.length)pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); }
|
||||||
|
|
||||||
// ── ADD FILES FROM SOP FOLDER (no-auth interim) ──────────────────────────────
|
// ── 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||''})),
|
signoffs:pkgSignoffs.map(s=>({role:s.role,name:s.name,date:s.date,signed:s.signed,dateReason:s.dateReason||''})),
|
||||||
holds:pkgHolds.map(h=>({...h})),
|
holds:pkgHolds.map(h=>({...h})),
|
||||||
qaRejections:pkgQaRejections.map(x=>({...x})),
|
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'),
|
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
|
kind: pkgKind, // 'iwp' (install) | 'ewp' (BIM) — drives which fields/types/gates apply
|
||||||
// AWP traceability: which BIM/model package(s) enabled this install package.
|
// 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>`);
|
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>`); }
|
add('materials', 'Material List', t+`</tbody></table>`); }
|
||||||
|
|
||||||
if(pkg.attachments&&pkg.attachments.length){
|
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</th></tr></thead><tbody>`;
|
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>`);
|
(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>`); }
|
// 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>
|
add('kitting', 'Kitting & MIMO', `<table><tbody>
|
||||||
<tr><th style="width:200px">Kitting Status</th><td>${cell(pkg.kitStatus)}</td></tr>
|
<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();
|
pkgSignoffs=(p.signoffs||[]).map(s=>({...s, fromSOP:!!s.name})); if(!pkgSignoffs.length) buildSignoffs(); else renderSignoffRows();
|
||||||
pkgHolds=(p.holds||[]).map(h=>({...h}));
|
pkgHolds=(p.holds||[]).map(h=>({...h}));
|
||||||
pkgQaRejections=(p.qaRejections||[]).map(x=>({...x}));
|
pkgQaRejections=(p.qaRejections||[]).map(x=>({...x}));
|
||||||
|
pkgFiles=(p.files||[]).map(x=>({...x})); wpFilesRender(); wpFilesRefreshUsage();
|
||||||
updateNumber(); updateReleaseBanner(); prevStatus=p.status||'Draft'; showForm(); wpFormPopulated();
|
updateNumber(); updateReleaseBanner(); prevStatus=p.status||'Draft'; showForm(); wpFormPopulated();
|
||||||
}
|
}
|
||||||
function renderConstraintRows(){ const tmp=pkgConstraints; pkgConstraints=[]; buildConstraints();
|
function renderConstraintRows(){ const tmp=pkgConstraints; pkgConstraints=[]; buildConstraints();
|
||||||
@@ -2497,7 +2618,7 @@ function newPackage(){
|
|||||||
pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach();
|
pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach();
|
||||||
pkgWorkSteps=['']; buildWorkSteps();
|
pkgWorkSteps=['']; buildWorkSteps();
|
||||||
pkgConstraints=[]; buildConstraints(); pkgSignoffs=[]; buildSignoffs();
|
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');
|
set_quality_from_sop(); lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
|
||||||
prevStatus='Draft';
|
prevStatus='Draft';
|
||||||
updateNumber(); updateReleaseBanner(); defaultOwnerToMe(); showForm(); wpFormPopulated(); renderWpNav(); track('new_package');
|
updateNumber(); updateReleaseBanner(); defaultOwnerToMe(); showForm(); wpFormPopulated(); renderWpNav(); track('new_package');
|
||||||
@@ -3269,6 +3390,7 @@ function bootData(){
|
|||||||
setRadio('status','Draft');
|
setRadio('status','Draft');
|
||||||
loadMembers();
|
loadMembers();
|
||||||
loadLocations(); // CR-004: the option lists come from the server
|
loadLocations(); // CR-004: the option lists come from the server
|
||||||
|
wpFilesRefreshUsage(); // CR-007: the storage meter is live before the first upload
|
||||||
initWpNavDrawer();
|
initWpNavDrawer();
|
||||||
renderSavedList();
|
renderSavedList();
|
||||||
positionSectionNav();
|
positionSectionNav();
|
||||||
|
|||||||
@@ -355,6 +355,20 @@
|
|||||||
<div class="sub-heading">Drawings & Attachments</div>
|
<div class="sub-heading">Drawings & Attachments</div>
|
||||||
<div id="sop-ref-links" class="sop-ref-links"></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>
|
<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="addAttach()">+ Add document</button>
|
||||||
<button class="add-btn" onclick="toggleSopFilePanel()">+ Add files from SOP folder</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);">
|
<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 { background:var(--red) !important; border-color:var(--red) !important; }
|
||||||
.pill-hold.selected .dot { background:var(--cds-text-on-color) !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 { 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;
|
.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); }
|
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
|
Run (prod): gunicorn -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 server.app:app
|
||||||
Interactive docs: http://<host>/api/docs
|
Interactive docs: http://<host>/api/docs
|
||||||
"""
|
"""
|
||||||
|
import base64
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
@@ -526,6 +527,25 @@ class TestEmailIn(BaseModel):
|
|||||||
to: Optional[str] = None
|
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):
|
class StatusIn(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
# CR-014: a rejection (Ready for QA -> In Progress) must say why. The upsert
|
# 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)
|
require_assignable(db, new_assignee, body.project_id)
|
||||||
wp.assignee_id = new_assignee
|
wp.assignee_id = new_assignee
|
||||||
wp.created_by = body.created_by or wp.created_by
|
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
|
wp.data = body.data
|
||||||
if is_new:
|
if is_new:
|
||||||
_act, _detail = "created", {"status": wp.status}
|
_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) ──────────────────────────────────────────────────────
|
# ── 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")
|
@app.get("/api/audit")
|
||||||
def list_audit(
|
def list_audit(
|
||||||
entity_type: Optional[str] = Query(None),
|
entity_type: Optional[str] = Query(None),
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ together.
|
|||||||
"""
|
"""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
from .db import Base
|
from .db import Base
|
||||||
|
|
||||||
@@ -347,6 +347,37 @@ class AppSetting(Base):
|
|||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
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):
|
class Notification(Base):
|
||||||
"""Outbox for user notifications (an in-app record + an optional email). A row
|
"""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
|
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