T5.4 - CR-005: a per-project location taxonomy, stored as codes
CLAUDE.md lists CR-005 among the change requests that get "silently half-built if
you treat them as frontend-only". This is the server half and the wizard half
together: a new table, four routes, an Alembic revision, and step 11.
CODES, NOT DISPLAY STRINGS, because CR-018 rolls cost up by these values and a
rollup keyed on a label breaks the day somebody fixes a typo in it. Two columns
carry that: `code` is a node's own slug, derived once at import and never
recomputed; `path` is the full slug path, unique per project, and is what a work
package will store. Renaming a value changes `name` alone - the probe renames a
floor and demands its path comes back byte-identical, with its children's paths
intact.
DEACTIVATE, NEVER DELETE. There is no DELETE route, and the probe checks for its
absence (405) rather than trusting that nobody added one. Deactivating hides a
value from new work packages and cascades DOWN, because a floor nobody can pick
must not keep offering its sectors. Reactivating walks UP only - a sector may
have been switched off for its own reasons, and silently resurrecting it would
undo a decision nobody made twice. That asymmetry is deliberate and is pinned by
a named check so it does not get "fixed" into a surprise.
Import reports rather than merges. Rejected rows come back with the SOURCE line
number and a reason; duplicates are listed as duplicates, separated into "already
in this project" and "already on line N of this import". Reusing a parent is not
a duplicate - B1/L2/1P and B1/L2/2P share a building and a floor by design, and
only the full path repeating counts. Re-importing a deactivated value brings the
same row back rather than creating a second one; the probe checks the id.
One parser, on the server. A CSV is read in the browser and posted as text
exactly as a paste is, so "what does a blank column mean" has one answer.
Comma, semicolon and tab all work - a paste out of a spreadsheet is tab
separated and a saved CSV is not, and which one somebody has is a question the
machine can answer.
No guessed floor names. IMPLEMENTATION.md section 8 says the B100 list has not
been supplied. The seeded sample has "Sample" inside every string, and the probe
greps html/ and server/ for a location-shaped assignment containing any of the
review's real names.
server/models.py LocationNode
server/alembic/versions/e2a4c7d91b30_location_taxonomy.py
server/app.py GET/POST/PATCH + import, parser, slug
html/work-package-suite.html step 11, an 11th rail button
html/work-package-suite-app.js the step's logic; LAST_STEP replaces 10
html/work-package-suite-styles.css the list, the report
html/theme-light.css .field-error, now declared once
tests/locations_check.py new - 58 checks
tests/stepper_check.py STEP_COUNT 10 -> 11
Done when
[x] CSV upload and paste both work and report rejected rows with reasons
[x] duplicates are detected and reported rather than silently merged
[x] values are editable after import - rename, add, deactivate
[x] deactivating hides it from new work packages; an existing package
referencing it still resolves, because the row is retained
[x] values are stored as codes suitable for grouping
[x] no guessed real-world floor names exist anywhere in the code
Two decisions worth disagreeing with
Step 11, appended, not step 2, inserted. Locations belong beside Project by
subject. Renumbering 2-10 would touch every sop-step-N id, every
collectStepData case, every gate key and the analytics history - a large
silent-mismatch surface for an ordering change. The count now lives in one
place (LAST_STEP), so reordering later is cheap.
Any project member may edit the list, not only a Project Admin. It matches how
the SOP baseline itself is authored: the Project Admin gate is on CHANGING a
completed SOP, not on writing one. If the location list should be tighter than
the SOP it belongs to, that is a product call.
Verified one at a time
locations_check 58/58 new
stepper_check 70/70 (11 steps)
browser_check 71/71
a11y 22/22 sop now rings 38 focusable elements
url_state 23/23
autosave 34/34
aggregates 16/16
pipeline 43/43
launcher 58/58
f_items F1-F5 FIXED, F6 REPRODUCES (T7.2)
alembic upgrade / downgrade / upgrade all clean on a throwaway SQLite
file, and the migrated schema matches Base.metadata.create_all
column for column - dev auto-creates and production migrates,
so a divergence between the two is invisible until it ships
.field-error was declared in two page sheets by the end of T5.2 and would have
been three by T5.8, so it moved to theme-light.css. No colour literal added
anywhere: still 0 across all page sheets and inline blocks.
Question for the PR, per CLAUDE.md: the levels are fixed at building / floor /
sector. Micron's floors behave like buildings, which this handles by letting a
project use whichever levels it needs - but a job that wants a fourth level, or
different names for the three, cannot say so. Whether that is worth a
per-project level vocabulary is a product question; the schema would take it
without a migration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,14 @@ roughly 11,900 lines" — the line count is right, the other two are not. See D1
|
|||||||
| `html/login.html` | login | 163 | `theme-light.css` | `login.js` | neither |
|
| `html/login.html` | login | 163 | `theme-light.css` | `login.js` | neither |
|
||||||
| `html/index.html` | launcher | 703 | `theme-light.css`, `wp-chrome.css` | `auth-guard`, `wp-format`, `feedback-config`, `project-data`, `help`, *(inline 418–700)*, `wp-chrome` | neither |
|
| `html/index.html` | launcher | 703 | `theme-light.css`, `wp-chrome.css` | `auth-guard`, `wp-format`, `feedback-config`, `project-data`, `help`, *(inline 418–700)*, `wp-chrome` | neither |
|
||||||
| `html/work-package-suite.html` | SOP wizard | 437 | `theme-light.css`, `wp-chrome.css`, `work-package-suite-styles.css` | `auth-guard`, `wp-format`, `feedback-config`, `project-data`, `help`, `work-package-suite-app`, `wp-chrome` | **hosts** |
|
| `html/work-package-suite.html` | SOP wizard | 437 | `theme-light.css`, `wp-chrome.css`, `work-package-suite-styles.css` | `auth-guard`, `wp-format`, `feedback-config`, `project-data`, `help`, `work-package-suite-app`, `wp-chrome` | **hosts** |
|
||||||
|
|
||||||
|
> **The SOP wizard has 11 steps, not 10, since `T5.4`.** `CR-005`'s location list was
|
||||||
|
> **appended** as step 11 rather than inserted beside Project, where it belongs by
|
||||||
|
> subject: renumbering 2–10 would touch every `sop-step-N` id, every `collectStepData`
|
||||||
|
> case, every gate key and the analytics history, for an ordering change. The count
|
||||||
|
> lives in one place — `LAST_STEP` in `work-package-suite-app.js` — and reordering is
|
||||||
|
> cheap once nothing depends on the numbers. `tests/stepper_check.py` names it
|
||||||
|
> `STEP_COUNT` for the same reason.
|
||||||
| `html/wp-creation-index.html` | creator | 401 | `theme-light.css`, `wp-creation-styles.css` | `auth-guard`, `wp-format`, `feedback-config`, `project-data`, `help`, `wp-creation-app` | **child** |
|
| `html/wp-creation-index.html` | creator | 401 | `theme-light.css`, `wp-creation-styles.css` | `auth-guard`, `wp-format`, `feedback-config`, `project-data`, `help`, `wp-creation-app` | **child** |
|
||||||
| `html/admin.html` | admin | 219 | `theme-light.css`, `wp-chrome.css`, `console.css`, `wp-sidenav.css` | `auth-guard`, `wp-format`, `console-util`, `admin`, `wp-chrome`, `wp-sidenav` | neither |
|
| `html/admin.html` | admin | 219 | `theme-light.css`, `wp-chrome.css`, `console.css`, `wp-sidenav.css` | `auth-guard`, `wp-format`, `console-util`, `admin`, `wp-chrome`, `wp-sidenav` | neither |
|
||||||
| `html/users.html` | directory/users | 106 | `theme-light.css`, `wp-chrome.css`, `console.css`, `wp-sidenav.css` | `auth-guard`, `wp-format`, `console-util`, `users`, `wp-chrome`, `wp-sidenav` | neither |
|
| `html/users.html` | directory/users | 106 | `theme-light.css`, `wp-chrome.css`, `console.css`, `wp-sidenav.css` | `auth-guard`, `wp-format`, `console-util`, `users`, `wp-chrome`, `wp-sidenav` | neither |
|
||||||
@@ -226,6 +234,7 @@ Wave 5 added more, for the same reason:
|
|||||||
python tests/stepper_check.py # A4/S9 — ten real buttons, keyboard operable 70 checks
|
python tests/stepper_check.py # A4/S9 — ten real buttons, keyboard operable 70 checks
|
||||||
python tests/launcher_check.py # B3 — can a brand-new account get started? 58 checks
|
python tests/launcher_check.py # B3 — can a brand-new account get started? 58 checks
|
||||||
python tests/pipeline_check.py # B4 surface — server counts, shareable links 43 checks
|
python tests/pipeline_check.py # B4 surface — server counts, shareable links 43 checks
|
||||||
|
python tests/locations_check.py # CR-005 — codes not labels, nothing deleted 58 checks
|
||||||
```
|
```
|
||||||
|
|
||||||
`pipeline_check.py` reads the dashboard's state out of the **iframe's DOM**, not its
|
`pipeline_check.py` reads the dashboard's state out of the **iframe's DOM**, not its
|
||||||
|
|||||||
@@ -311,11 +311,8 @@
|
|||||||
BL-014 as controls falling back to the UA focus ring; two of that entry's
|
BL-014 as controls falling back to the UA focus ring; two of that entry's
|
||||||
four sites no longer exist. */
|
four sites no longer exist. */
|
||||||
|
|
||||||
/* An inline error at the field it belongs to, rather than a native dialog that
|
/* .field-error — the inline error beside a field — is declared once, in
|
||||||
names no field and highlights nothing (C1, and the pattern T5.8 uses in the
|
theme-light.css, because three surfaces in wave 5 grew one. */
|
||||||
wizard). :empty so the element can sit in the markup and announce on change. */
|
|
||||||
.field-error { color: var(--cds-text-error); font-size: 12px; font-weight: 600; margin-top: 0.3rem; }
|
|
||||||
.field-error:empty { display: none; }
|
|
||||||
|
|
||||||
/* PIPELINE STRIP — B4 surface (T5.3)
|
/* PIPELINE STRIP — B4 surface (T5.3)
|
||||||
Four counts, every one of them from /api/wps/metrics. There is deliberately
|
Four counts, every one of them from /api/wps/metrics. There is deliberately
|
||||||
|
|||||||
@@ -508,6 +508,22 @@ input, textarea, select {
|
|||||||
.wp-draft-retry:hover { background: var(--wp-btn-danger-soft-bg); }
|
.wp-draft-retry:hover { background: var(--wp-btn-danger-soft-bg); }
|
||||||
.wp-draft-retry:focus-visible { outline: 2px solid var(--cds-focus); outline-offset: -2px; }
|
.wp-draft-retry:focus-visible { outline: 2px solid var(--cds-focus); outline-offset: -2px; }
|
||||||
|
|
||||||
|
/* An inline validation error, at the field it belongs to (C1).
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
Three surfaces grew one of these within wave 5 — the launcher's create-project
|
||||||
|
form (T5.2), the SOP wizard's location list (T5.4), and T5.8's step validation
|
||||||
|
— so it lives here rather than as three page-sheet rules that would drift.
|
||||||
|
The markup pairs it with aria-describedby and role="alert" on the element; the
|
||||||
|
:empty rule is what lets it sit in the page permanently and announce on change
|
||||||
|
rather than being created at the moment somebody needs to hear it. */
|
||||||
|
.field-error {
|
||||||
|
color: var(--cds-text-error);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.field-error:empty { display: none; }
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
FOCUS (S12 / T4.7)
|
FOCUS (S12 / T4.7)
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -309,7 +309,7 @@ window.addEventListener('DOMContentLoaded',()=>{
|
|||||||
else if(tab === 'wp' || tab === 'sop') switchTool(tab, {fromUrl:true});
|
else if(tab === 'wp' || tab === 'sop') switchTool(tab, {fromUrl:true});
|
||||||
else if(_deepLinkWp) switchTool('wp', {fromUrl:true});
|
else if(_deepLinkWp) switchTool('wp', {fromUrl:true});
|
||||||
const bootStep = parseInt(params.get('step'), 10);
|
const bootStep = parseInt(params.get('step'), 10);
|
||||||
if(bootStep >= 1 && bootStep <= 10 && currentTool === 'sop') goToStep(bootStep, {fromUrl:true});
|
if(bootStep >= 1 && bootStep <= LAST_STEP && currentTool === 'sop') goToStep(bootStep, {fromUrl:true});
|
||||||
}
|
}
|
||||||
if(projId && typeof ProjectData!=='undefined' && ProjectData.pullProject){
|
if(projId && typeof ProjectData!=='undefined' && ProjectData.pullProject){
|
||||||
ProjectData.pullProject(projId).then(afterPull).catch(afterPull);
|
ProjectData.pullProject(projId).then(afterPull).catch(afterPull);
|
||||||
@@ -439,7 +439,7 @@ function loadSampleData(){
|
|||||||
|
|
||||||
// Switch to step 1. Every step now holds sample content, so the rail marks them
|
// Switch to step 1. Every step now holds sample content, so the rail marks them
|
||||||
// all as visited — otherwise a fully populated wizard shows ten unstarted steps.
|
// all as visited — otherwise a fully populated wizard shows ten unstarted steps.
|
||||||
for(let i = 1; i <= 10; i++) _visitedSteps.add(i);
|
for(let i = 1; i <= LAST_STEP; i++) _visitedSteps.add(i);
|
||||||
currentStep = 1;
|
currentStep = 1;
|
||||||
updateStepUI();
|
updateStepUI();
|
||||||
updateProjectDisplay();
|
updateProjectDisplay();
|
||||||
@@ -472,7 +472,7 @@ function restoreSavedSOP(){
|
|||||||
repopulateForm();
|
repopulateForm();
|
||||||
// A completed SOP has been all the way through: the rail marks every step done
|
// A completed SOP has been all the way through: the rail marks every step done
|
||||||
// rather than showing ten unstarted steps over a finished configuration.
|
// rather than showing ten unstarted steps over a finished configuration.
|
||||||
for(let i = 1; i <= 10; i++) _visitedSteps.add(i);
|
for(let i = 1; i <= LAST_STEP; i++) _visitedSteps.add(i);
|
||||||
|
|
||||||
if(typeof onSOPReady === 'function') onSOPReady(sop);
|
if(typeof onSOPReady === 'function') onSOPReady(sop);
|
||||||
}
|
}
|
||||||
@@ -1213,6 +1213,294 @@ function addSource(){
|
|||||||
renderSources();
|
renderSources();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── STEP 11: LOCATION LIST (CR-005) ───────────────────────────────────────────
|
||||||
|
// Server-backed throughout. CLAUDE.md: "If a task touches one of these and you
|
||||||
|
// find yourself writing to localStorage, you are building the wrong thing."
|
||||||
|
// CR-018 rolls cost up by these values, and a taxonomy each browser keeps its own
|
||||||
|
// copy of cannot be rolled up by anything.
|
||||||
|
//
|
||||||
|
// The list is fetched with include_inactive=true here, deliberately: this is the
|
||||||
|
// screen where you MANAGE the list, so a value somebody deactivated has to be
|
||||||
|
// visible in order to be brought back. The work package form asks for the default
|
||||||
|
// (active only), which is what makes deactivating hide it from new packages.
|
||||||
|
|
||||||
|
// Obviously fake, and labelled as such in the values themselves. IMPLEMENTATION.md
|
||||||
|
// section 8: the B100 floor and area list has not been supplied, and hardcoding a
|
||||||
|
// guess would put invented Micron floor names into the repository. "Sample" is in
|
||||||
|
// every string so nobody can mistake one for real data.
|
||||||
|
const LOCATION_SAMPLE = [
|
||||||
|
'Sample Building One,Sample Level 1,Sample Sector A',
|
||||||
|
'Sample Building One,Sample Level 1,Sample Sector B',
|
||||||
|
'Sample Building One,Sample Level 2,Sample Sector A',
|
||||||
|
'Sample Building Two,Sample Level 1,Sample Sector A',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
let _locNodes = [];
|
||||||
|
let _locLoaded = false;
|
||||||
|
|
||||||
|
function locProjectId(){
|
||||||
|
try {
|
||||||
|
const fromUrl = new URLSearchParams(window.location.search).get('project');
|
||||||
|
if(fromUrl) return fromUrl;
|
||||||
|
if(typeof ProjectData !== 'undefined' && ProjectData.getActiveId) return ProjectData.getActiveId() || '';
|
||||||
|
} catch(e){}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
function locApi(suffix){
|
||||||
|
return '/api/projects/' + encodeURIComponent(locProjectId()) + '/locations' + (suffix || '');
|
||||||
|
}
|
||||||
|
function locEsc(v){ return escAttr(v); }
|
||||||
|
|
||||||
|
// Every message on this step goes through here so the role is decided in one
|
||||||
|
// place: a report that lost rows interrupts (T4.5), a clean one does not.
|
||||||
|
function locSay(html, isProblem){
|
||||||
|
const el = document.getElementById('loc-report');
|
||||||
|
if(!el) return;
|
||||||
|
el.setAttribute('role', isProblem ? 'alert' : 'status');
|
||||||
|
el.innerHTML = html || '';
|
||||||
|
el.classList.toggle('is-problem', !!isProblem);
|
||||||
|
}
|
||||||
|
|
||||||
|
function locSetAddError(msg){
|
||||||
|
const el = document.getElementById('loc-add-err');
|
||||||
|
if(el) el.textContent = msg || '';
|
||||||
|
const input = document.getElementById('loc-add-name');
|
||||||
|
if(input){
|
||||||
|
if(msg) input.setAttribute('aria-invalid', 'true');
|
||||||
|
else input.removeAttribute('aria-invalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function locLoad(force){
|
||||||
|
const tool = document.getElementById('loc-tool');
|
||||||
|
const warn = document.getElementById('loc-noproject');
|
||||||
|
const pid = locProjectId();
|
||||||
|
if(!pid){
|
||||||
|
if(tool) tool.style.display = 'none';
|
||||||
|
if(warn){
|
||||||
|
warn.style.display = '';
|
||||||
|
warn.textContent = 'Open this wizard from a project to configure its location list. '
|
||||||
|
+ 'The list is stored against the project on the server, not in this browser.';
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
if(tool) tool.style.display = '';
|
||||||
|
if(warn) warn.style.display = 'none';
|
||||||
|
if(_locLoaded && !force) return Promise.resolve();
|
||||||
|
return fetch(locApi('?include_inactive=true'), {headers:{'Accept':'application/json'}})
|
||||||
|
.then(r => { if(!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||||
|
.then(data => { _locNodes = data.nodes || []; _locLoaded = true; locRender(); })
|
||||||
|
.catch(err => {
|
||||||
|
_locLoaded = false;
|
||||||
|
locRender();
|
||||||
|
locSay('⚠ Could not load the location list — ' + locEsc((err && err.message) || 'offline')
|
||||||
|
+ '. It is stored on the server, so nothing local is shown in its place.', true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function locRender(){
|
||||||
|
const list = document.getElementById('loc-list');
|
||||||
|
const count = document.getElementById('loc-count');
|
||||||
|
if(!list) return;
|
||||||
|
const active = _locNodes.filter(n => n.active);
|
||||||
|
if(count){
|
||||||
|
count.textContent = _locNodes.length
|
||||||
|
? `${active.length} value${active.length===1?'':'s'} in use`
|
||||||
|
+ (_locNodes.length > active.length ? `, ${_locNodes.length - active.length} deactivated` : '')
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
if(!_locNodes.length){
|
||||||
|
list.innerHTML = '<p class="field-hint">Nothing configured yet. Paste or upload a list above, '
|
||||||
|
+ 'or add values one at a time.</p>';
|
||||||
|
locRenderParentPicker();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Ordered by path from the server, so a plain walk is already the tree order.
|
||||||
|
list.innerHTML = _locNodes.map(n => {
|
||||||
|
const depth = (n.path.match(/\//g) || []).length;
|
||||||
|
return `<div class="loc-row loc-depth-${depth}${n.active ? '' : ' is-off'}" data-id="${locEsc(n.id)}">
|
||||||
|
<span class="loc-level">${locEsc(n.level)}</span>
|
||||||
|
<input class="loc-name" type="text" value="${locEsc(n.name)}"
|
||||||
|
aria-label="Name for ${locEsc(n.path)}" data-id="${locEsc(n.id)}">
|
||||||
|
<code class="loc-code" title="The stable code cost rolls up by. Renaming does not change it.">${locEsc(n.path)}</code>
|
||||||
|
<label class="loc-toggle"><input type="checkbox" class="loc-active" data-id="${locEsc(n.id)}"
|
||||||
|
${n.active ? 'checked' : ''}><span>In use</span></label>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
locRenderParentPicker();
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a hand-added value can hang off. A building has nothing above it, so the
|
||||||
|
// empty option is a real choice rather than a prompt.
|
||||||
|
function locRenderParentPicker(){
|
||||||
|
const sel = document.getElementById('loc-add-parent');
|
||||||
|
if(!sel) return;
|
||||||
|
const keep = sel.value;
|
||||||
|
const opts = ['<option value="">(nothing — add a building)</option>'].concat(
|
||||||
|
_locNodes.filter(n => n.active && n.level !== 'sector')
|
||||||
|
.map(n => `<option value="${locEsc(n.id)}">${locEsc(n.path)} — adds a ${n.level === 'building' ? 'floor' : 'sector'}</option>`)
|
||||||
|
);
|
||||||
|
sel.innerHTML = opts.join('');
|
||||||
|
if(keep && Array.from(sel.options).some(o => o.value === keep)) sel.value = keep;
|
||||||
|
}
|
||||||
|
|
||||||
|
function locReport(res){
|
||||||
|
const bits = [];
|
||||||
|
const problem = (res.rejected || []).length > 0 || (res.duplicates || []).length > 0;
|
||||||
|
const verb = res.dry_run ? 'would be added' : 'added';
|
||||||
|
bits.push(`<p class="loc-report-line"><strong>${res.read} row${res.read===1?'':'s'} read.</strong> `
|
||||||
|
+ `${(res.created||[]).length} value${(res.created||[]).length===1?'':'s'} ${verb}`
|
||||||
|
+ ((res.reactivated||[]).length ? `, ${res.reactivated.length} brought back into use` : '')
|
||||||
|
+ `.</p>`);
|
||||||
|
const rowList = (title, rows, fmt) => {
|
||||||
|
if(!rows || !rows.length) return '';
|
||||||
|
return `<div class="loc-report-group"><div class="loc-report-title">${locEsc(title)} (${rows.length})</div>`
|
||||||
|
+ '<ul class="loc-report-list">' + rows.map(fmt).join('') + '</ul></div>';
|
||||||
|
};
|
||||||
|
bits.push(rowList('Rejected', res.rejected,
|
||||||
|
r => `<li><span class="loc-line">row ${r.line}</span> <code>${locEsc(r.text)}</code> — ${locEsc(r.reason)}</li>`));
|
||||||
|
bits.push(rowList('Duplicates, not merged', res.duplicates,
|
||||||
|
r => `<li><span class="loc-line">row ${r.line}</span> <code>${locEsc(r.path)}</code> — ${locEsc(r.reason)}</li>`));
|
||||||
|
if(!problem && !(res.created||[]).length && !(res.reactivated||[]).length){
|
||||||
|
bits.push('<p class="loc-report-line">Nothing to do — every row is already on this project.</p>');
|
||||||
|
}
|
||||||
|
locSay(bits.join(''), problem);
|
||||||
|
}
|
||||||
|
|
||||||
|
function locImport(dryRun){
|
||||||
|
const text = (document.getElementById('loc-paste') || {}).value || '';
|
||||||
|
if(!text.trim()){
|
||||||
|
locSay('Paste some rows or choose a CSV file first.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(!locProjectId()){ locLoad(); return; }
|
||||||
|
locSay('Checking…', false);
|
||||||
|
fetch(locApi('/import'), {
|
||||||
|
method: 'POST', headers: {'Content-Type':'application/json','Accept':'application/json'},
|
||||||
|
body: JSON.stringify({text: text, dry_run: !!dryRun}),
|
||||||
|
})
|
||||||
|
.then(r => r.json().then(j => ({ok: r.ok, status: r.status, body: j})))
|
||||||
|
.then(res => {
|
||||||
|
if(!res.ok){
|
||||||
|
locSay('⚠ Import refused — ' + locEsc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
locReport(res.body);
|
||||||
|
if(!dryRun) return locLoad(true);
|
||||||
|
})
|
||||||
|
.catch(err => locSay('⚠ Could not reach the server — ' + locEsc((err && err.message) || 'offline')
|
||||||
|
+ '. Nothing was imported.', true));
|
||||||
|
}
|
||||||
|
|
||||||
|
function locAdd(){
|
||||||
|
const nameEl = document.getElementById('loc-add-name');
|
||||||
|
const parentEl = document.getElementById('loc-add-parent');
|
||||||
|
const name = ((nameEl || {}).value || '').trim();
|
||||||
|
if(!name){
|
||||||
|
locSetAddError('Enter a name for the value you are adding.');
|
||||||
|
if(nameEl) nameEl.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
locSetAddError('');
|
||||||
|
const parentId = (parentEl || {}).value || '';
|
||||||
|
const parent = _locNodes.find(n => n.id === parentId);
|
||||||
|
const level = !parent ? 'building' : (parent.level === 'building' ? 'floor' : 'sector');
|
||||||
|
fetch(locApi(''), {
|
||||||
|
method: 'POST', headers: {'Content-Type':'application/json','Accept':'application/json'},
|
||||||
|
body: JSON.stringify({level: level, parent_id: parentId || null, name: name}),
|
||||||
|
})
|
||||||
|
.then(r => r.json().then(j => ({ok: r.ok, status: r.status, body: j})))
|
||||||
|
.then(res => {
|
||||||
|
if(!res.ok){
|
||||||
|
locSetAddError((res.body && res.body.detail) || ('Could not add it (HTTP ' + res.status + ')'));
|
||||||
|
if(nameEl) nameEl.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(nameEl) nameEl.value = '';
|
||||||
|
locSay(`Added <code>${locEsc(res.body.path)}</code>.`, false);
|
||||||
|
return locLoad(true);
|
||||||
|
})
|
||||||
|
.catch(err => locSetAddError('Could not reach the server — ' + ((err && err.message) || 'offline')));
|
||||||
|
}
|
||||||
|
|
||||||
|
function locPatch(id, patch, describe){
|
||||||
|
return fetch(locApi('/' + encodeURIComponent(id)), {
|
||||||
|
method: 'PATCH', headers: {'Content-Type':'application/json','Accept':'application/json'},
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
})
|
||||||
|
.then(r => r.json().then(j => ({ok: r.ok, status: r.status, body: j})))
|
||||||
|
.then(res => {
|
||||||
|
if(!res.ok){
|
||||||
|
locSay('⚠ ' + locEsc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
|
||||||
|
return locLoad(true);
|
||||||
|
}
|
||||||
|
locSay(describe(res.body), false);
|
||||||
|
return locLoad(true);
|
||||||
|
})
|
||||||
|
.catch(err => locSay('⚠ Could not reach the server — ' + locEsc((err && err.message) || 'offline'), true));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function(){
|
||||||
|
const paste = document.getElementById('loc-paste');
|
||||||
|
const file = document.getElementById('loc-file');
|
||||||
|
const btn = id => document.getElementById(id);
|
||||||
|
|
||||||
|
if(btn('loc-file-btn')) btn('loc-file-btn').addEventListener('click', () => file && file.click());
|
||||||
|
if(file) file.addEventListener('change', function(ev){
|
||||||
|
const f = ev.target.files && ev.target.files[0];
|
||||||
|
if(!f) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
// One parser, on the server. Reading the file here and posting its text is
|
||||||
|
// what stops "what does a blank column mean" having two answers.
|
||||||
|
if(paste) paste.value = String(reader.result || '');
|
||||||
|
locSay(`Read <strong>${locEsc(f.name)}</strong>. Check it, then import.`, false);
|
||||||
|
};
|
||||||
|
reader.onerror = () => locSay('⚠ Could not read that file.', true);
|
||||||
|
reader.readAsText(f);
|
||||||
|
ev.target.value = '';
|
||||||
|
});
|
||||||
|
if(btn('loc-check-btn')) btn('loc-check-btn').addEventListener('click', () => locImport(true));
|
||||||
|
if(btn('loc-import-btn')) btn('loc-import-btn').addEventListener('click', () => locImport(false));
|
||||||
|
if(btn('loc-sample-btn')) btn('loc-sample-btn').addEventListener('click', function(){
|
||||||
|
if(paste) paste.value = LOCATION_SAMPLE;
|
||||||
|
locSay('Sample values loaded into the box — obviously fake, and safe to import '
|
||||||
|
+ 'while the real list is being collected. Check them, then import.', false);
|
||||||
|
});
|
||||||
|
if(btn('loc-add-btn')) btn('loc-add-btn').addEventListener('click', locAdd);
|
||||||
|
const addName = btn('loc-add-name');
|
||||||
|
if(addName) addName.addEventListener('keydown', e => { if(e.key === 'Enter'){ e.preventDefault(); locAdd(); } });
|
||||||
|
|
||||||
|
const list = document.getElementById('loc-list');
|
||||||
|
if(list){
|
||||||
|
// Rename on blur, not on every keystroke: a rename is a write, and one per
|
||||||
|
// character would be a hundred writes for a hundred-character name.
|
||||||
|
list.addEventListener('change', function(e){
|
||||||
|
const t = e.target;
|
||||||
|
if(!t) return;
|
||||||
|
if(t.classList.contains('loc-active')){
|
||||||
|
const node = _locNodes.find(n => n.id === t.dataset.id);
|
||||||
|
locPatch(t.dataset.id, {active: t.checked},
|
||||||
|
b => b.active
|
||||||
|
? `<code>${locEsc(b.path)}</code> is back in use.`
|
||||||
|
: `<code>${locEsc(b.path)}</code> is no longer offered on new work packages. `
|
||||||
|
+ 'Existing packages that reference it are unchanged.');
|
||||||
|
if(node) node.active = t.checked;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(t.classList.contains('loc-name')){
|
||||||
|
const node = _locNodes.find(n => n.id === t.dataset.id);
|
||||||
|
const next = (t.value || '').trim();
|
||||||
|
if(!node || next === node.name){ if(node) t.value = node.name; return; }
|
||||||
|
if(!next){ t.value = node.name; return; }
|
||||||
|
locPatch(t.dataset.id, {name: next},
|
||||||
|
b => `Renamed to “${locEsc(b.name)}”. Its code <code>${locEsc(b.path)}</code> is unchanged, `
|
||||||
|
+ 'so work packages already using it still point at the same value.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── STEP GATES (A4 / S9) ──────────────────────────────────────────────────────
|
// ── STEP GATES (A4 / S9) ──────────────────────────────────────────────────────
|
||||||
// validateStep() guarded steps 1, 5 and 6 with three hand-written conditions and
|
// validateStep() guarded steps 1, 5 and 6 with three hand-written conditions and
|
||||||
// three hand-written messages. The rail needs the same answer for every step, not
|
// three hand-written messages. The rail needs the same answer for every step, not
|
||||||
@@ -1237,7 +1525,9 @@ const STEP_GATES = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const STEP_LABELS = {1:'Project', 2:'Team', 3:'Sign-offs', 4:'WP types', 5:'Governance',
|
const STEP_LABELS = {1:'Project', 2:'Team', 3:'Sign-offs', 4:'WP types', 5:'Governance',
|
||||||
6:'Quality', 7:'Platforms', 8:'Sequence', 9:'Constraints', 10:'Sources'};
|
6:'Quality', 7:'Platforms', 8:'Sequence', 9:'Constraints', 10:'Sources',
|
||||||
|
11:'Locations'};
|
||||||
|
const LAST_STEP = 11;
|
||||||
|
|
||||||
// Steps you have actually been on. A stepper's tick means "done", and a step you
|
// Steps you have actually been on. A stepper's tick means "done", and a step you
|
||||||
// have never opened is not done however its defaults happen to read — step 6's QC
|
// have never opened is not done however its defaults happen to read — step 6's QC
|
||||||
@@ -1307,7 +1597,7 @@ if(typeof WPUrl !== 'undefined'){
|
|||||||
|
|
||||||
function nextStep(){
|
function nextStep(){
|
||||||
if(!validateStep(currentStep)) return;
|
if(!validateStep(currentStep)) return;
|
||||||
if(currentStep < 10){
|
if(currentStep < LAST_STEP){
|
||||||
railMessage('');
|
railMessage('');
|
||||||
currentStep++;
|
currentStep++;
|
||||||
_visitedSteps.add(currentStep);
|
_visitedSteps.add(currentStep);
|
||||||
@@ -1375,6 +1665,8 @@ function renderStepRail(){
|
|||||||
});
|
});
|
||||||
const pos = document.getElementById('step-rail-pos');
|
const pos = document.getElementById('step-rail-pos');
|
||||||
if(pos) pos.textContent = currentStep;
|
if(pos) pos.textContent = currentStep;
|
||||||
|
const total = document.getElementById('step-rail-total');
|
||||||
|
if(total) total.textContent = LAST_STEP;
|
||||||
const here = document.getElementById('step-rail-here');
|
const here = document.getElementById('step-rail-here');
|
||||||
if(here) here.textContent = STEP_LABELS[currentStep] || '';
|
if(here) here.textContent = STEP_LABELS[currentStep] || '';
|
||||||
}
|
}
|
||||||
@@ -1475,10 +1767,15 @@ function updateStepUI(){
|
|||||||
|
|
||||||
renderStepRail();
|
renderStepRail();
|
||||||
|
|
||||||
|
// The location list lives on the server, so it is fetched when the step is
|
||||||
|
// actually opened rather than on every page load. locLoad() is idempotent and
|
||||||
|
// no-ops once it has the list.
|
||||||
|
if(currentStep === 11 && typeof locLoad === 'function') locLoad();
|
||||||
|
|
||||||
// Update buttons
|
// Update buttons
|
||||||
document.getElementById('sop-prev-btn').disabled = currentStep === 1;
|
document.getElementById('sop-prev-btn').disabled = currentStep === 1;
|
||||||
document.getElementById('sop-next-btn').style.display = currentStep < 10 ? 'block' : 'none';
|
document.getElementById('sop-next-btn').style.display = currentStep < LAST_STEP ? 'block' : 'none';
|
||||||
document.getElementById('sop-complete-btn').style.display = currentStep === 10 ? 'block' : 'none';
|
document.getElementById('sop-complete-btn').style.display = currentStep === LAST_STEP ? 'block' : 'none';
|
||||||
|
|
||||||
// Collect form data
|
// Collect form data
|
||||||
collectStepData();
|
collectStepData();
|
||||||
@@ -1558,7 +1855,7 @@ function completeSOP(){
|
|||||||
'Ask a project admin to make the change — the SOP is the baseline every work package inherits.');
|
'Ask a project admin to make the change — the SOP is the baseline every work package inherits.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if(!validateStep(10)) return;
|
if(!validateStep(LAST_STEP)) return;
|
||||||
collectStepData();
|
collectStepData();
|
||||||
|
|
||||||
sop = {
|
sop = {
|
||||||
@@ -1800,7 +2097,7 @@ function showAnalytics(){
|
|||||||
const s = analyticsSummary();
|
const s = analyticsSummary();
|
||||||
const fmtMin = ms => (ms/60000).toFixed(1)+' min';
|
const fmtMin = ms => (ms/60000).toFixed(1)+' min';
|
||||||
let txt = `USAGE LOGS\n\nSessions: ${s.sessions} Events: ${s.total}\nRange: ${s.first?new Date(s.first).toLocaleString():'—'} → ${s.last?new Date(s.last).toLocaleString():'—'}\n\nStep views:\n`;
|
let txt = `USAGE LOGS\n\nSessions: ${s.sessions} Events: ${s.total}\nRange: ${s.first?new Date(s.first).toLocaleString():'—'} → ${s.last?new Date(s.last).toLocaleString():'—'}\n\nStep views:\n`;
|
||||||
for(let i=1;i<=10;i++) txt += ` Step ${i}: ${s.byStep[i]||0} views` + (s.dwell[i]?`, ${fmtMin(s.dwell[i])} total`:'') + `\n`;
|
for(let i=1;i<=LAST_STEP;i++) txt += ` Step ${i}: ${s.byStep[i]||0} views` + (s.dwell[i]?`, ${fmtMin(s.dwell[i])} total`:'') + `\n`;
|
||||||
txt += `\nActions:\n`;
|
txt += `\nActions:\n`;
|
||||||
Object.keys(s.byEvent).filter(k=>!['step_view','step_dwell','field_edit'].includes(k)).forEach(k=> txt += ` ${k}: ${s.byEvent[k]}\n`);
|
Object.keys(s.byEvent).filter(k=>!['step_view','step_dwell','field_edit'].includes(k)).forEach(k=> txt += ` ${k}: ${s.byEvent[k]}\n`);
|
||||||
txt += ` field edits: ${s.byEvent['field_edit']||0}\n`;
|
txt += ` field edits: ${s.byEvent['field_edit']||0}\n`;
|
||||||
|
|||||||
@@ -625,6 +625,83 @@ body.embed-full { overflow: hidden; }
|
|||||||
|
|
||||||
.nav-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
.nav-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ══ LOCATION LIST — CR-005 (step 11) ═════════════════════════════════════════
|
||||||
|
Building / floor / sector, configured per project. The code beside each name
|
||||||
|
is the thing CR-018 rolls cost up by, and it is shown rather than hidden so it
|
||||||
|
is obvious that renaming a value does not move it. */
|
||||||
|
.loc-actions { display: flex; gap: var(--wp-s2); flex-wrap: wrap; margin-top: var(--wp-s3); }
|
||||||
|
|
||||||
|
.loc-report { margin-top: var(--wp-s3); font-size: 13px; }
|
||||||
|
.loc-report:empty { display: none; }
|
||||||
|
.loc-report-line { margin: 0 0 var(--wp-s2); }
|
||||||
|
.loc-report-group { margin-top: var(--wp-s3); }
|
||||||
|
.loc-report-title { font-size: 12px; font-weight: 600; color: var(--text-light); }
|
||||||
|
.loc-report-list { margin: var(--wp-s1) 0 0; padding-left: var(--wp-s5); }
|
||||||
|
.loc-report-list li { margin-bottom: var(--wp-s1); color: var(--text-light); }
|
||||||
|
.loc-line { font-weight: 600; color: var(--text); }
|
||||||
|
/* A report that lost rows says so in its own right, not only by wording. */
|
||||||
|
.loc-report.is-problem { border-left: 3px solid var(--warning); padding-left: var(--wp-s3);
|
||||||
|
background: var(--warning-bg); padding-top: var(--wp-s2); padding-bottom: var(--wp-s2); }
|
||||||
|
|
||||||
|
#loc-list { display: flex; flex-direction: column; margin-top: var(--wp-s3); }
|
||||||
|
.loc-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--wp-s3);
|
||||||
|
padding: var(--wp-s2);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
/* Indentation is the hierarchy, and it is not the only cue — .loc-level names
|
||||||
|
the level in words beside it (C1: never colour or position alone). */
|
||||||
|
.loc-depth-1 { padding-left: var(--wp-s5); }
|
||||||
|
.loc-depth-2 { padding-left: var(--wp-s6); }
|
||||||
|
.loc-level {
|
||||||
|
flex: none; min-width: 68px;
|
||||||
|
font-size: 11px; font-weight: 600; letter-spacing: .04em;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
.loc-name {
|
||||||
|
flex: 1 1 180px; min-width: 0;
|
||||||
|
padding: var(--wp-s1) var(--wp-s2);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--cds-field);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.loc-code {
|
||||||
|
flex: none;
|
||||||
|
font-family: var(--wp-font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
.loc-toggle { display: flex; align-items: center; gap: var(--wp-s1); font-size: 12px; color: var(--text-light); }
|
||||||
|
/* A deactivated value is still listed — that is how it is brought back — but it
|
||||||
|
reads as out of use, by strikethrough as well as by the unticked box. */
|
||||||
|
.loc-row.is-off .loc-name { text-decoration: line-through; color: var(--text-light); }
|
||||||
|
.loc-row.is-off .loc-code { text-decoration: line-through; }
|
||||||
|
|
||||||
|
.loc-addrow {
|
||||||
|
display: flex; align-items: center; gap: var(--wp-s2); flex-wrap: wrap;
|
||||||
|
margin-top: var(--wp-s4);
|
||||||
|
}
|
||||||
|
.loc-addlabel { font-size: 12px; font-weight: 600; color: var(--text-light); }
|
||||||
|
.loc-addrow select, .loc-addrow input {
|
||||||
|
padding: var(--wp-s2);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--cds-field);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.loc-addrow input { flex: 1 1 200px; min-width: 0; }
|
||||||
|
|
||||||
|
/* .field-error is declared once, in theme-light.css — the launcher's create form
|
||||||
|
and T5.8's step validation use the same component. */
|
||||||
|
|
||||||
/* NAVIGATION */
|
/* NAVIGATION */
|
||||||
.step-navigation {
|
.step-navigation {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
where-you-are and opens on demand. -->
|
where-you-are and opens on demand. -->
|
||||||
<button type="button" class="step-rail-toggle" id="step-rail-toggle"
|
<button type="button" class="step-rail-toggle" id="step-rail-toggle"
|
||||||
aria-expanded="false" aria-controls="step-rail-list">
|
aria-expanded="false" aria-controls="step-rail-list">
|
||||||
<span class="step-rail-toggle-text">Step <span id="step-rail-pos">1</span> of 10 · <span id="step-rail-here">Project</span></span>
|
<span class="step-rail-toggle-text">Step <span id="step-rail-pos">1</span> of <span id="step-rail-total">11</span> · <span id="step-rail-here">Project</span></span>
|
||||||
<span class="step-rail-toggle-chev" aria-hidden="true">▾</span>
|
<span class="step-rail-toggle-chev" aria-hidden="true">▾</span>
|
||||||
</button>
|
</button>
|
||||||
<ol class="step-rail-list" id="step-rail-list">
|
<ol class="step-rail-list" id="step-rail-list">
|
||||||
@@ -98,6 +98,13 @@
|
|||||||
<li class="step-rail-item"><button type="button" class="step-btn" data-step="8"><span class="step-btn-marker" aria-hidden="true">8</span><span class="step-btn-body"><span class="step-btn-label">Sequence</span><span class="step-btn-state"></span></span></button></li>
|
<li class="step-rail-item"><button type="button" class="step-btn" data-step="8"><span class="step-btn-marker" aria-hidden="true">8</span><span class="step-btn-body"><span class="step-btn-label">Sequence</span><span class="step-btn-state"></span></span></button></li>
|
||||||
<li class="step-rail-item"><button type="button" class="step-btn" data-step="9"><span class="step-btn-marker" aria-hidden="true">9</span><span class="step-btn-body"><span class="step-btn-label">Constraints</span><span class="step-btn-state"></span></span></button></li>
|
<li class="step-rail-item"><button type="button" class="step-btn" data-step="9"><span class="step-btn-marker" aria-hidden="true">9</span><span class="step-btn-body"><span class="step-btn-label">Constraints</span><span class="step-btn-state"></span></span></button></li>
|
||||||
<li class="step-rail-item"><button type="button" class="step-btn" data-step="10"><span class="step-btn-marker" aria-hidden="true">10</span><span class="step-btn-body"><span class="step-btn-label">Sources</span><span class="step-btn-state"></span></span></button></li>
|
<li class="step-rail-item"><button type="button" class="step-btn" data-step="10"><span class="step-btn-marker" aria-hidden="true">10</span><span class="step-btn-body"><span class="step-btn-label">Sources</span><span class="step-btn-state"></span></span></button></li>
|
||||||
|
<!-- CR-005. Appended rather than inserted next to Project, which is where
|
||||||
|
it belongs by subject: renumbering steps 2-10 would touch every
|
||||||
|
sop-step-N id, every collectStepData case, every gate key and the
|
||||||
|
analytics history, for an ordering change. Numbers are cheap to
|
||||||
|
reorder once nothing depends on them; a renumbering buried in a
|
||||||
|
feature diff is not. -->
|
||||||
|
<li class="step-rail-item"><button type="button" class="step-btn" data-step="11"><span class="step-btn-marker" aria-hidden="true">11</span><span class="step-btn-body"><span class="step-btn-label">Locations</span><span class="step-btn-state"></span></span></button></li>
|
||||||
</ol>
|
</ol>
|
||||||
<!-- Why a step you asked for did not open. role="alert" because a refused
|
<!-- Why a step you asked for did not open. role="alert" because a refused
|
||||||
navigation is an error and waiting for a pause to say so is too late
|
navigation is an error and waiting for a pause to say so is too late
|
||||||
@@ -385,6 +392,61 @@
|
|||||||
<button class="add-btn" onclick="addSource()">+ Add source</button>
|
<button class="add-btn" onclick="addSource()">+ Add source</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- STEP 11: LOCATIONS (CR-005)
|
||||||
|
Server-backed, not localStorage: CR-018 rolls cost up by these
|
||||||
|
values, and a taxonomy each browser keeps its own copy of cannot
|
||||||
|
be rolled up by anything. See CLAUDE.md, "Frontend and backend
|
||||||
|
boundary". -->
|
||||||
|
<div class="step" id="sop-step-11" style="display: none;">
|
||||||
|
<h2>11. Location List</h2>
|
||||||
|
<div class="notice">Building, floor and sector for this project. The shape of a
|
||||||
|
location differs per job — on some, floors within one building behave like
|
||||||
|
separate buildings and are the unit of both execution and cost tracking — so it
|
||||||
|
is configured here rather than assumed. Work packages pick from this list, and
|
||||||
|
cost rolls up by it.</div>
|
||||||
|
|
||||||
|
<div id="loc-noproject" class="notice" style="display:none; background:var(--warning-bg); color:var(--warning);"></div>
|
||||||
|
|
||||||
|
<div id="loc-tool">
|
||||||
|
<div class="field-grid col1">
|
||||||
|
<div class="field">
|
||||||
|
<label for="loc-paste">Paste rows, or upload a CSV</label>
|
||||||
|
<textarea id="loc-paste" rows="6" aria-describedby="loc-paste-hint"
|
||||||
|
placeholder="One row per sector, e.g. Building, Floor, Sector"></textarea>
|
||||||
|
<small id="loc-paste-hint">One row per value, deepest level last:
|
||||||
|
<strong>building, floor, sector</strong>. Two columns describe a floor,
|
||||||
|
one describes a building. Comma, semicolon or tab separated — a paste
|
||||||
|
straight out of a spreadsheet works. A header row is ignored.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="loc-actions">
|
||||||
|
<input type="file" id="loc-file" accept=".csv,.txt,text/csv,text/plain" hidden>
|
||||||
|
<button type="button" class="add-btn" id="loc-file-btn">Choose a CSV file…</button>
|
||||||
|
<button type="button" class="add-btn" id="loc-check-btn">Check without importing</button>
|
||||||
|
<button type="button" class="add-btn" id="loc-import-btn">Import</button>
|
||||||
|
<button type="button" class="add-btn" id="loc-sample-btn">Load sample values</button>
|
||||||
|
</div>
|
||||||
|
<!-- Rejected rows and duplicates land here, with line numbers. An
|
||||||
|
import that says "42 rows" over a file with 50 in it has lost
|
||||||
|
eight and told nobody, which is what this exists to prevent. -->
|
||||||
|
<div class="loc-report" id="loc-report" role="status"></div>
|
||||||
|
|
||||||
|
<div style="margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;">
|
||||||
|
<div class="sub-heading">Current list</div>
|
||||||
|
<p class="field-hint" id="loc-count"></p>
|
||||||
|
<div id="loc-list"></div>
|
||||||
|
<div class="loc-addrow">
|
||||||
|
<label class="loc-addlabel" for="loc-add-parent">Add under</label>
|
||||||
|
<select id="loc-add-parent"></select>
|
||||||
|
<label class="loc-addlabel" for="loc-add-name">Name</label>
|
||||||
|
<input type="text" id="loc-add-name" placeholder="e.g. a new sector">
|
||||||
|
<button type="button" class="add-btn" id="loc-add-btn">+ Add</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-error" id="loc-add-err" role="alert"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SOP NAVIGATION -->
|
<!-- SOP NAVIGATION -->
|
||||||
|
|||||||
65
server/alembic/versions/e2a4c7d91b30_location_taxonomy.py
Normal file
65
server/alembic/versions/e2a4c7d91b30_location_taxonomy.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""per-project Building / Floor / Sector taxonomy (CR-005)
|
||||||
|
|
||||||
|
The location taxonomy differs per project — on Micron, floors within B100 behave
|
||||||
|
like separate buildings — so it is configured once per SOP instead of hard-coded.
|
||||||
|
CR-018 rolls cost up by these values, which is why the table stores CODES
|
||||||
|
(`code`, `path`) beside the display `name`: a rollup keyed on a label breaks the
|
||||||
|
day somebody fixes a typo in it.
|
||||||
|
|
||||||
|
`active` rather than a delete. Deactivating hides a value from new work packages
|
||||||
|
while every package already referencing it still resolves its label, which is the
|
||||||
|
same rule CR-002 and CR-016 apply to fields.
|
||||||
|
|
||||||
|
Additive only: a new table, no change to any existing one, so nothing to backfill
|
||||||
|
and nothing to migrate.
|
||||||
|
|
||||||
|
Revision ID: e2a4c7d91b30
|
||||||
|
Revises: a7c31f9e5b02
|
||||||
|
Create Date: 2026-08-16 09:41:02.118307
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'e2a4c7d91b30'
|
||||||
|
down_revision = 'a7c31f9e5b02'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'location_nodes',
|
||||||
|
sa.Column('id', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('project_id', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('parent_id', sa.String(length=40), nullable=True),
|
||||||
|
sa.Column('level', sa.String(length=20), nullable=False, server_default='building'),
|
||||||
|
sa.Column('code', sa.String(length=60), nullable=False, server_default=''),
|
||||||
|
sa.Column('path', sa.String(length=200), nullable=False, server_default=''),
|
||||||
|
sa.Column('name', sa.String(length=200), nullable=False, server_default=''),
|
||||||
|
sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column('sort', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('created_by', sa.String(length=200), nullable=False, server_default=''),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
# One row per (project, path). This is what makes a re-import report a
|
||||||
|
# duplicate instead of quietly creating a second B100/L2/1P.
|
||||||
|
sa.UniqueConstraint('project_id', 'path', name='uq_location_path'),
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_location_nodes_project_id'), 'location_nodes',
|
||||||
|
['project_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_location_nodes_parent_id'), 'location_nodes',
|
||||||
|
['parent_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_location_nodes_path'), 'location_nodes', ['path'], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f('ix_location_nodes_path'), table_name='location_nodes')
|
||||||
|
op.drop_index(op.f('ix_location_nodes_parent_id'), table_name='location_nodes')
|
||||||
|
op.drop_index(op.f('ix_location_nodes_project_id'), table_name='location_nodes')
|
||||||
|
op.drop_table('location_nodes')
|
||||||
344
server/app.py
344
server/app.py
@@ -1868,6 +1868,350 @@ def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] =
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Location taxonomy — CR-005 ────────────────────────────────────────────────
|
||||||
|
# Building / Floor / Sector, configured once per project rather than hard-coded.
|
||||||
|
# See models.LocationNode for why this stores codes as well as names, and why
|
||||||
|
# nothing here deletes.
|
||||||
|
|
||||||
|
LOCATION_LEVELS = ("building", "floor", "sector")
|
||||||
|
_SLUG_STRIP = re.compile(r"[^A-Z0-9]+")
|
||||||
|
|
||||||
|
|
||||||
|
def location_slug(name: str) -> str:
|
||||||
|
"""A stable code from a display name: upper-cased, non-alphanumerics collapsed
|
||||||
|
to a single dash, trimmed. `Level 2` -> `LEVEL-2`, `1P (chase)` -> `1P-CHASE`.
|
||||||
|
|
||||||
|
Derived ONCE, at import, and never recomputed — see LocationNode. Returns ''
|
||||||
|
when there is nothing to make a code from, which the caller turns into a
|
||||||
|
rejected row with a reason rather than a silently-skipped one."""
|
||||||
|
return _SLUG_STRIP.sub("-", (name or "").strip().upper()).strip("-")[:60]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_location_rows(text: str) -> tuple[list[tuple[int, list[str]]], list[dict]]:
|
||||||
|
"""Split pasted or uploaded text into (rows, rejected).
|
||||||
|
|
||||||
|
Rows come back paired with their SOURCE line number, not their index among the
|
||||||
|
accepted ones. "Duplicate on row 12" has to mean row 12 of the file somebody is
|
||||||
|
looking at, or the report sends them to the wrong line.
|
||||||
|
|
||||||
|
Accepts comma, tab or semicolon separators — a paste out of Excel is
|
||||||
|
tab-separated and a saved CSV is not, and asking which one somebody has is a
|
||||||
|
question the machine can answer. A header line naming the levels is skipped.
|
||||||
|
|
||||||
|
Every rejection carries the line number and a reason. A silent skip is the
|
||||||
|
failure mode this endpoint exists to avoid: an import that says "42 rows" over
|
||||||
|
a file with 50 in it has lost eight and told nobody."""
|
||||||
|
rows: list[tuple[int, list[str]]] = []
|
||||||
|
rejected: list[dict] = []
|
||||||
|
for i, raw in enumerate((text or "").splitlines(), start=1):
|
||||||
|
line = raw.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if "\t" in raw:
|
||||||
|
parts = [c.strip() for c in raw.split("\t")]
|
||||||
|
elif ";" in line and "," not in line:
|
||||||
|
parts = [c.strip() for c in line.split(";")]
|
||||||
|
else:
|
||||||
|
parts = [c.strip() for c in line.split(",")]
|
||||||
|
parts = [p.strip().strip('"').strip() for p in parts]
|
||||||
|
while parts and not parts[-1]:
|
||||||
|
parts.pop()
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
low = [p.lower() for p in parts]
|
||||||
|
if i == 1 and low[:1] in (["building"], ["bldg"]):
|
||||||
|
continue # header row
|
||||||
|
if len(parts) > len(LOCATION_LEVELS):
|
||||||
|
rejected.append({"line": i, "text": line,
|
||||||
|
"reason": "more than 3 columns — expected building, floor, sector"})
|
||||||
|
continue
|
||||||
|
if not parts[0]:
|
||||||
|
rejected.append({"line": i, "text": line,
|
||||||
|
"reason": "no building — a floor or sector needs one above it"})
|
||||||
|
continue
|
||||||
|
# A gap in the middle ("B100,,1P") would attach a sector to nothing.
|
||||||
|
gap = next((n for n, p in enumerate(parts) if not p), None)
|
||||||
|
if gap is not None and any(parts[gap + 1:]):
|
||||||
|
rejected.append({"line": i, "text": line,
|
||||||
|
"reason": "a %s is named with no %s above it"
|
||||||
|
% (LOCATION_LEVELS[len(parts) - 1], LOCATION_LEVELS[gap])})
|
||||||
|
continue
|
||||||
|
parts = [p for p in parts if p]
|
||||||
|
if any(not location_slug(p) for p in parts):
|
||||||
|
rejected.append({"line": i, "text": line,
|
||||||
|
"reason": "no letters or digits to make a code from"})
|
||||||
|
continue
|
||||||
|
rows.append((i, parts))
|
||||||
|
return rows, rejected
|
||||||
|
|
||||||
|
|
||||||
|
def _location_tree(db: Session, project_id: str, include_inactive: bool):
|
||||||
|
stmt = select(models.LocationNode).where(models.LocationNode.project_id == project_id)
|
||||||
|
if not include_inactive:
|
||||||
|
stmt = stmt.where(models.LocationNode.active.is_(True))
|
||||||
|
nodes = db.scalars(stmt.order_by(models.LocationNode.path)).all()
|
||||||
|
return nodes
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/projects/{project_id}/locations")
|
||||||
|
def list_locations(project_id: str,
|
||||||
|
include_inactive: bool = Query(False),
|
||||||
|
user: models.User = Depends(auth.get_current_user),
|
||||||
|
db: Session = Depends(get_db)):
|
||||||
|
"""The project's taxonomy, flat, ordered by path so a caller can rebuild the
|
||||||
|
tree without a second query.
|
||||||
|
|
||||||
|
`include_inactive` defaults to FALSE, which is what makes a deactivated value
|
||||||
|
disappear from new work packages. It is available as true because a package
|
||||||
|
that already references a deactivated value still has to render its label —
|
||||||
|
deactivating hides a choice, it does not rewrite history."""
|
||||||
|
if not db.get(models.Project, project_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
require_project_access(db, user, project_id)
|
||||||
|
nodes = _location_tree(db, project_id, include_inactive)
|
||||||
|
return {
|
||||||
|
"project_id": project_id,
|
||||||
|
"levels": list(LOCATION_LEVELS),
|
||||||
|
"nodes": [n.to_dict() for n in nodes],
|
||||||
|
"counts": {lvl: sum(1 for n in nodes if n.level == lvl) for lvl in LOCATION_LEVELS},
|
||||||
|
"generated_at": models.utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LocationImportIn(BaseModel):
|
||||||
|
text: str = ""
|
||||||
|
dry_run: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/projects/{project_id}/locations/import")
|
||||||
|
def import_locations(project_id: str, body: LocationImportIn,
|
||||||
|
user: models.User = Depends(auth.get_current_user),
|
||||||
|
db: Session = Depends(get_db)):
|
||||||
|
"""Bulk import from CSV or a paste. Same code path for both: a file is read in
|
||||||
|
the browser and posted as text, because two parsers would be two sets of rules
|
||||||
|
about what a blank column means.
|
||||||
|
|
||||||
|
Reports rather than merges. A row naming a building/floor/sector combination
|
||||||
|
that already exists — in this file or in the project — comes back in
|
||||||
|
`duplicates` with its line number. Reusing a PARENT is not a duplicate:
|
||||||
|
`B100,L2,1P` and `B100,L2,2P` share a building and a floor by design, and it
|
||||||
|
is only the full path repeating that is a duplicate.
|
||||||
|
|
||||||
|
`dry_run` parses and reports without writing, which is what lets the wizard
|
||||||
|
show what an import will do before it does it."""
|
||||||
|
if not db.get(models.Project, project_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
# Any project member, matching how the SOP baseline itself is authored — the
|
||||||
|
# Project Admin gate is on CHANGING a completed SOP, not on writing one.
|
||||||
|
require_project_access(db, user, project_id)
|
||||||
|
require_project_writable(db, user, project_id, "The location list cannot be changed")
|
||||||
|
|
||||||
|
rows, rejected = parse_location_rows(body.text)
|
||||||
|
|
||||||
|
existing = {n.path: n for n in db.scalars(
|
||||||
|
select(models.LocationNode).where(models.LocationNode.project_id == project_id)
|
||||||
|
).all()}
|
||||||
|
before = set(existing)
|
||||||
|
created: list[dict] = []
|
||||||
|
duplicates: list[dict] = []
|
||||||
|
reactivated: list[dict] = []
|
||||||
|
seen_in_file: dict[str, int] = {}
|
||||||
|
next_sort = max((n.sort for n in existing.values()), default=0)
|
||||||
|
|
||||||
|
for line_no, parts in rows:
|
||||||
|
segs = [location_slug(p) for p in parts]
|
||||||
|
full = "/".join(segs)
|
||||||
|
if full in seen_in_file:
|
||||||
|
duplicates.append({"line": line_no, "path": full, "names": parts,
|
||||||
|
"reason": "already on line %d of this import" % seen_in_file[full]})
|
||||||
|
continue
|
||||||
|
seen_in_file[full] = line_no
|
||||||
|
if full in before:
|
||||||
|
node = existing[full]
|
||||||
|
if not node.active:
|
||||||
|
# Re-importing a value somebody deactivated is a request to bring it
|
||||||
|
# back, not a duplicate — and it must reuse the SAME row, or every
|
||||||
|
# work package pointing at the old path is orphaned.
|
||||||
|
if not body.dry_run:
|
||||||
|
node.active = True
|
||||||
|
reactivated.append({"path": full, "names": parts})
|
||||||
|
else:
|
||||||
|
duplicates.append({"line": line_no, "path": full, "names": parts,
|
||||||
|
"reason": "already in this project"})
|
||||||
|
continue
|
||||||
|
# Create any missing ancestors, then the leaf. Sharing a parent is the
|
||||||
|
# normal case, not a collision.
|
||||||
|
parent_id = None
|
||||||
|
for depth, seg in enumerate(segs):
|
||||||
|
sub = "/".join(segs[:depth + 1])
|
||||||
|
node = existing.get(sub)
|
||||||
|
if node is None:
|
||||||
|
next_sort += 1
|
||||||
|
node = models.LocationNode(
|
||||||
|
id=gen_id("loc"), project_id=project_id, parent_id=parent_id,
|
||||||
|
level=LOCATION_LEVELS[depth], code=seg, path=sub,
|
||||||
|
name=parts[depth], active=True, sort=next_sort,
|
||||||
|
created_by=user.username,
|
||||||
|
)
|
||||||
|
existing[sub] = node
|
||||||
|
if not body.dry_run:
|
||||||
|
db.add(node)
|
||||||
|
if sub not in before:
|
||||||
|
created.append({"path": sub, "level": LOCATION_LEVELS[depth],
|
||||||
|
"code": seg, "name": parts[depth]})
|
||||||
|
parent_id = node.id
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"project_id": project_id, "dry_run": bool(body.dry_run),
|
||||||
|
"read": len(rows) + len(rejected),
|
||||||
|
"created": created, "duplicates": duplicates,
|
||||||
|
"reactivated": reactivated, "rejected": rejected,
|
||||||
|
}
|
||||||
|
if body.dry_run:
|
||||||
|
db.rollback()
|
||||||
|
return result
|
||||||
|
log_event(db, user, "locations_imported", "project", project_id, project_id,
|
||||||
|
summary="%d location value(s) added" % len(created),
|
||||||
|
detail={"created": len(created), "duplicates": len(duplicates),
|
||||||
|
"rejected": len(rejected), "reactivated": len(reactivated)})
|
||||||
|
db.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class LocationIn(BaseModel):
|
||||||
|
level: str = "building"
|
||||||
|
parent_id: Optional[str] = None
|
||||||
|
name: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/projects/{project_id}/locations")
|
||||||
|
def add_location(project_id: str, body: LocationIn,
|
||||||
|
user: models.User = Depends(auth.get_current_user),
|
||||||
|
db: Session = Depends(get_db)):
|
||||||
|
"""Add one value by hand. Same rules as the import — an import you cannot
|
||||||
|
correct afterwards is an import nobody trusts enough to run."""
|
||||||
|
if not db.get(models.Project, project_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
# Any project member, matching how the SOP baseline itself is authored — the
|
||||||
|
# Project Admin gate is on CHANGING a completed SOP, not on writing one.
|
||||||
|
require_project_access(db, user, project_id)
|
||||||
|
require_project_writable(db, user, project_id, "The location list cannot be changed")
|
||||||
|
|
||||||
|
name = (body.name or "").strip()
|
||||||
|
code = location_slug(name)
|
||||||
|
if not code:
|
||||||
|
raise HTTPException(status_code=400,
|
||||||
|
detail="That name has no letters or digits to make a code from")
|
||||||
|
level = (body.level or "").strip().lower()
|
||||||
|
if level not in LOCATION_LEVELS:
|
||||||
|
raise HTTPException(status_code=400,
|
||||||
|
detail="Level must be one of: " + ", ".join(LOCATION_LEVELS))
|
||||||
|
depth = LOCATION_LEVELS.index(level)
|
||||||
|
parent = None
|
||||||
|
if body.parent_id:
|
||||||
|
parent = db.get(models.LocationNode, body.parent_id)
|
||||||
|
if not parent or parent.project_id != project_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Parent is not on this project")
|
||||||
|
if depth == 0 and parent is not None:
|
||||||
|
raise HTTPException(status_code=400, detail="A building has nothing above it")
|
||||||
|
if depth > 0 and parent is None:
|
||||||
|
raise HTTPException(status_code=400,
|
||||||
|
detail="A %s needs a %s above it" % (level, LOCATION_LEVELS[depth - 1]))
|
||||||
|
if parent is not None and LOCATION_LEVELS.index(parent.level) != depth - 1:
|
||||||
|
raise HTTPException(status_code=400,
|
||||||
|
detail="A %s cannot sit under a %s" % (level, parent.level))
|
||||||
|
|
||||||
|
path = (parent.path + "/" + code) if parent else code
|
||||||
|
clash = db.scalars(select(models.LocationNode).where(
|
||||||
|
(models.LocationNode.project_id == project_id) & (models.LocationNode.path == path)
|
||||||
|
)).first()
|
||||||
|
if clash:
|
||||||
|
if not clash.active:
|
||||||
|
clash.active = True
|
||||||
|
log_event(db, user, "location_reactivated", "project", project_id, project_id,
|
||||||
|
summary=path)
|
||||||
|
db.commit()
|
||||||
|
return clash.to_dict()
|
||||||
|
raise HTTPException(status_code=409, detail="“%s” is already on this project" % name)
|
||||||
|
|
||||||
|
top = db.scalar(select(func.max(models.LocationNode.sort)).where(
|
||||||
|
models.LocationNode.project_id == project_id)) or 0
|
||||||
|
node = models.LocationNode(
|
||||||
|
id=gen_id("loc"), project_id=project_id, parent_id=(parent.id if parent else None),
|
||||||
|
level=level, code=code, path=path, name=name, active=True, sort=top + 1,
|
||||||
|
created_by=user.username,
|
||||||
|
)
|
||||||
|
db.add(node)
|
||||||
|
log_event(db, user, "location_added", "project", project_id, project_id, summary=path)
|
||||||
|
db.commit()
|
||||||
|
return node.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
class LocationPatch(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/projects/{project_id}/locations/{node_id}")
|
||||||
|
def update_location(project_id: str, node_id: str, body: LocationPatch,
|
||||||
|
user: models.User = Depends(auth.get_current_user),
|
||||||
|
db: Session = Depends(get_db)):
|
||||||
|
"""Rename or deactivate. There is no DELETE, and that is the design:
|
||||||
|
|
||||||
|
rename changes `name` only. `code` and `path` are untouched, so every
|
||||||
|
work package pointing at this value keeps pointing at it. That
|
||||||
|
is the whole reason the two are separate columns.
|
||||||
|
deactivate hides the value from new work packages. Cascades DOWN — a floor
|
||||||
|
nobody can pick makes its sectors unpickable too, and leaving
|
||||||
|
them offered would be offering a path to nowhere. Reactivating
|
||||||
|
a child reactivates its ancestors for the same reason.
|
||||||
|
"""
|
||||||
|
if not db.get(models.Project, project_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
# Any project member, matching how the SOP baseline itself is authored — the
|
||||||
|
# Project Admin gate is on CHANGING a completed SOP, not on writing one.
|
||||||
|
require_project_access(db, user, project_id)
|
||||||
|
require_project_writable(db, user, project_id, "The location list cannot be changed")
|
||||||
|
node = db.get(models.LocationNode, node_id)
|
||||||
|
if not node or node.project_id != project_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Location not found on this project")
|
||||||
|
|
||||||
|
changed = {}
|
||||||
|
if body.name is not None:
|
||||||
|
name = body.name.strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="A name cannot be empty")
|
||||||
|
if name != node.name:
|
||||||
|
changed["name"] = {"from": node.name, "to": name}
|
||||||
|
node.name = name
|
||||||
|
if body.active is not None and bool(body.active) != bool(node.active):
|
||||||
|
changed["active"] = {"from": bool(node.active), "to": bool(body.active)}
|
||||||
|
node.active = bool(body.active)
|
||||||
|
if not node.active:
|
||||||
|
for child in db.scalars(select(models.LocationNode).where(
|
||||||
|
(models.LocationNode.project_id == project_id)
|
||||||
|
& (models.LocationNode.path.like(node.path + "/%"))
|
||||||
|
)).all():
|
||||||
|
child.active = False
|
||||||
|
else:
|
||||||
|
# Walk up by path rather than by parent_id: one query, and it cannot
|
||||||
|
# loop on a malformed chain.
|
||||||
|
segs = node.path.split("/")
|
||||||
|
ancestors = ["/".join(segs[:n]) for n in range(1, len(segs))]
|
||||||
|
if ancestors:
|
||||||
|
for anc in db.scalars(select(models.LocationNode).where(
|
||||||
|
(models.LocationNode.project_id == project_id)
|
||||||
|
& (models.LocationNode.path.in_(ancestors))
|
||||||
|
)).all():
|
||||||
|
anc.active = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
log_event(db, user, "location_updated", "project", project_id, project_id,
|
||||||
|
summary=node.path, detail=changed)
|
||||||
|
db.commit()
|
||||||
|
return node.to_dict()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/projects/{project_id}/summary")
|
@app.get("/api/projects/{project_id}/summary")
|
||||||
def project_summary(project_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
def project_summary(project_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||||
"""What the launcher needs to describe a project without asking the browser
|
"""What the launcher needs to describe a project without asking the browser
|
||||||
|
|||||||
@@ -224,6 +224,68 @@ class ProjectMember(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class LocationNode(Base):
|
||||||
|
"""One value in a project's Building / Floor / Sector taxonomy — CR-005.
|
||||||
|
|
||||||
|
The taxonomy differs per project. On Micron, floors within B100 behave like
|
||||||
|
separate buildings, so floor and sector are the unit of both execution and
|
||||||
|
cost tracking; on another job "building" may be the only level that means
|
||||||
|
anything. So it is configured once per SOP rather than hard-coded, and no
|
||||||
|
real-world floor name appears anywhere in this repository.
|
||||||
|
|
||||||
|
CODES, NOT DISPLAY STRINGS. `CR-018` rolls cost up by these, and a rollup
|
||||||
|
keyed on a label breaks the day somebody fixes a typo in it. Two columns
|
||||||
|
carry that:
|
||||||
|
|
||||||
|
code this node's own slug among its siblings, derived once from the name
|
||||||
|
it was imported with and then NEVER recomputed — renaming a node is
|
||||||
|
a display change, which is exactly what makes rename safe for the
|
||||||
|
work packages already pointing at it.
|
||||||
|
path the full slug path from the root, '/'-joined and unique per project
|
||||||
|
(`B100/L2/1P`). This is the grouping key and the value a work
|
||||||
|
package stores.
|
||||||
|
|
||||||
|
DEACTIVATE, NEVER DELETE. `active=False` hides a value from new work
|
||||||
|
packages; every existing package referencing it still resolves its label,
|
||||||
|
because the row is still there. Same rule as `CR-002`/`CR-016`: removal is
|
||||||
|
expressed as a toggle, and the data is retained.
|
||||||
|
"""
|
||||||
|
__tablename__ = "location_nodes"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("project_id", "path", name="uq_location_path"),
|
||||||
|
)
|
||||||
|
|
||||||
|
LEVELS = ("building", "floor", "sector")
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||||
|
project_id: Mapped[str] = mapped_column(
|
||||||
|
String(40), ForeignKey("projects.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
# Self-reference by id. No ForeignKey to its own table for the same reason the
|
||||||
|
# rest of this file declares none — see the module docstring — and because a
|
||||||
|
# self-referential FK plus SQLite's deferred-constraint behaviour makes a bulk
|
||||||
|
# import fiddly for no gain. Orphans are prevented in the API, which is the
|
||||||
|
# only writer.
|
||||||
|
parent_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
||||||
|
level: Mapped[str] = mapped_column(String(20), default="building") # building | floor | sector
|
||||||
|
code: Mapped[str] = mapped_column(String(60), default="") # own slug
|
||||||
|
path: Mapped[str] = mapped_column(String(200), default="", index=True) # full slug path
|
||||||
|
name: Mapped[str] = mapped_column(String(200), default="") # display label
|
||||||
|
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
sort: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
created_by: Mapped[str] = mapped_column(String(200), default="")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id, "project_id": self.project_id, "parent_id": self.parent_id,
|
||||||
|
"level": self.level, "code": self.code, "path": self.path, "name": self.name,
|
||||||
|
"active": bool(self.active), "sort": self.sort,
|
||||||
|
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Comment(Base):
|
class Comment(Base):
|
||||||
__tablename__ = "comments"
|
__tablename__ = "comments"
|
||||||
|
|
||||||
|
|||||||
437
tests/locations_check.py
Normal file
437
tests/locations_check.py
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Per-project Building / Floor / Sector taxonomy — CR-005 (T5.4).
|
||||||
|
|
||||||
|
CLAUDE.md names this as one of the change requests that gets "silently half-built
|
||||||
|
if you treat it as frontend-only": it needs structured location storage and
|
||||||
|
aggregate endpoints, not localStorage. So the API is tested directly as well as
|
||||||
|
through the wizard, and one check greps the wizard's own code for a localStorage
|
||||||
|
write behind the list.
|
||||||
|
|
||||||
|
1. CSV upload and paste both work, and report rejected rows WITH reasons
|
||||||
|
2. duplicates are detected and reported, not silently merged
|
||||||
|
3. values are editable after import — rename and add
|
||||||
|
4. deactivating hides a value from new work packages; a package already
|
||||||
|
referencing it still resolves
|
||||||
|
5. values are stored as codes suitable for grouping, and renaming does not
|
||||||
|
move the code
|
||||||
|
6. no guessed real-world floor name exists anywhere in the code
|
||||||
|
|
||||||
|
Check 5 is the one with teeth. `CR-018` rolls cost up by these values, so the
|
||||||
|
grouping key has to survive a rename — the probe renames a node and demands its
|
||||||
|
path is byte-identical afterwards.
|
||||||
|
|
||||||
|
Exit 0 all passed, 1 a failure, 2 could not run.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
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__)))
|
||||||
|
|
||||||
|
import cdp # noqa: E402
|
||||||
|
from browser_check import seed, start_server, chk, _PASS, _FAIL, _c # noqa: E402
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
STUB = """
|
||||||
|
window.__dialogs = [];
|
||||||
|
window.alert = function (m) { window.__dialogs.push(['alert', String(m)]); };
|
||||||
|
window.confirm = function (m) { window.__dialogs.push(['confirm', String(m)]); return false; };
|
||||||
|
window.prompt = function (m) { window.__dialogs.push(['prompt', String(m)]); return null; };
|
||||||
|
true
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Deliberately not a real building. IMPLEMENTATION.md section 8: the B100 floor
|
||||||
|
# and area list has not been supplied, and a probe that invented one would put a
|
||||||
|
# guessed Micron floor name in the repository just as surely as the app would.
|
||||||
|
GOOD = "\n".join([
|
||||||
|
"Building,Floor,Sector",
|
||||||
|
"Probe Building One,Probe Level 1,Probe Sector A",
|
||||||
|
"Probe Building One,Probe Level 1,Probe Sector B",
|
||||||
|
"Probe Building One,Probe Level 2,Probe Sector A",
|
||||||
|
"Probe Building Two,Probe Level 1,Probe Sector A",
|
||||||
|
])
|
||||||
|
|
||||||
|
MESSY = "\n".join([
|
||||||
|
"Probe Building One,Probe Level 1,Probe Sector A", # duplicate of the above
|
||||||
|
",Probe Level 9,Probe Sector Z", # no building
|
||||||
|
"Probe Building Three,,Probe Sector Q", # sector with no floor
|
||||||
|
"Probe Building Three,Probe Level 1,Probe Sector A,too many",
|
||||||
|
"---,Probe Level 1", # no code can be made
|
||||||
|
"Probe Building Three,Probe Level 1,Probe Sector A", # genuinely new
|
||||||
|
"Probe Building Three,Probe Level 1,Probe Sector A", # duplicate within the file
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def settle(seconds=1.4):
|
||||||
|
time.sleep(seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def api(page, method, path, body=None):
|
||||||
|
"""Call the API from inside the page, so the session cookie rides along."""
|
||||||
|
js = """(() => {
|
||||||
|
const opts = {method: %s, headers: {'Accept':'application/json'}};
|
||||||
|
%s
|
||||||
|
return fetch(%s, opts).then(r => r.text().then(t => JSON.stringify(
|
||||||
|
{status: r.status, body: (() => { try { return JSON.parse(t); } catch (e) { return t; } })()})));
|
||||||
|
})()""" % (
|
||||||
|
json.dumps(method),
|
||||||
|
("opts.headers['Content-Type']='application/json'; opts.body=%s;" % json.dumps(json.dumps(body)))
|
||||||
|
if body is not None else "",
|
||||||
|
json.dumps(path),
|
||||||
|
)
|
||||||
|
return json.loads(page.eval(js))
|
||||||
|
|
||||||
|
|
||||||
|
def open_step11(page, base, tok, project="projA"):
|
||||||
|
page.clear_cookies()
|
||||||
|
page.set_cookie("wp_session", tok["root"])
|
||||||
|
page.goto(base + "/work-package-suite.html?project=%s&step=11" % project)
|
||||||
|
settle(2.0)
|
||||||
|
page.eval(STUB)
|
||||||
|
|
||||||
|
|
||||||
|
def rows_in_list(page):
|
||||||
|
return json.loads(page.eval("""JSON.stringify(
|
||||||
|
[...document.querySelectorAll('#loc-list .loc-row')].map(r => ({
|
||||||
|
level: (r.querySelector('.loc-level')||{}).textContent,
|
||||||
|
name: (r.querySelector('.loc-name')||{}).value,
|
||||||
|
code: (r.querySelector('.loc-code')||{}).textContent,
|
||||||
|
active: !!(r.querySelector('.loc-active')||{}).checked,
|
||||||
|
off: r.classList.contains('is-off'),
|
||||||
|
})))"""))
|
||||||
|
|
||||||
|
|
||||||
|
def run(page, base, tok):
|
||||||
|
print("\n1. paste imports, and reports what it refused")
|
||||||
|
open_step11(page, base, tok)
|
||||||
|
chk("the wizard has an eleventh step and it is showing",
|
||||||
|
page.eval("getComputedStyle(document.getElementById('sop-step-11')).display") == "block",
|
||||||
|
page.eval("(document.getElementById('sop-step-11')||{}).id"))
|
||||||
|
chk("...named Locations in the rail",
|
||||||
|
page.eval("(document.querySelector('#step-rail-list .step-btn[data-step=\"11\"] "
|
||||||
|
".step-btn-label')||{}).textContent") == "Locations")
|
||||||
|
chk("...and it is the last step, so SOP complete moved with it",
|
||||||
|
page.eval("getComputedStyle(document.getElementById('sop-complete-btn')).display")
|
||||||
|
!= "none")
|
||||||
|
chk("the page boots with no JavaScript errors", not page.js_errors(), page.js_errors())
|
||||||
|
|
||||||
|
page.eval("document.getElementById('loc-paste').value = %s" % json.dumps(GOOD))
|
||||||
|
page.eval("document.getElementById('loc-check-btn').click()")
|
||||||
|
settle(1.2)
|
||||||
|
report = page.eval("(document.getElementById('loc-report')||{}).textContent||''")
|
||||||
|
# Four, not five: the header line is recognised and skipped. Asserted at the
|
||||||
|
# number rather than "greater than zero", because "read 4 of a 5-line file" is
|
||||||
|
# exactly the kind of quiet loss this report exists to make visible.
|
||||||
|
chk("checking without importing reads every data row, header skipped",
|
||||||
|
"4 rows read" in report, repr(report[:120]))
|
||||||
|
chk("...and says what it would create without creating it",
|
||||||
|
"9 values would be added" in report, repr(report[:120]))
|
||||||
|
chk("...and writes nothing", api(page, "GET", "/api/projects/projA/locations")
|
||||||
|
["body"]["nodes"] == [], api(page, "GET", "/api/projects/projA/locations")["body"])
|
||||||
|
|
||||||
|
page.eval("document.getElementById('loc-import-btn').click()")
|
||||||
|
settle(1.6)
|
||||||
|
listed = rows_in_list(page)
|
||||||
|
chk("importing creates the whole hierarchy, parents included", len(listed) == 9,
|
||||||
|
[r["code"] for r in listed])
|
||||||
|
chk("...two buildings", sum(1 for r in listed if r["level"] == "building") == 2,
|
||||||
|
[r["code"] for r in listed])
|
||||||
|
chk("...three floors", sum(1 for r in listed if r["level"] == "floor") == 3,
|
||||||
|
[r["code"] for r in listed])
|
||||||
|
chk("...four sectors", sum(1 for r in listed if r["level"] == "sector") == 4,
|
||||||
|
[r["code"] for r in listed])
|
||||||
|
chk("...and a shared parent is created once, not once per row",
|
||||||
|
[r["code"] for r in listed].count("PROBE-BUILDING-ONE") == 1,
|
||||||
|
[r["code"] for r in listed])
|
||||||
|
|
||||||
|
print("\n5. the values are codes, and a rename does not move one")
|
||||||
|
codes = [r["code"] for r in listed]
|
||||||
|
chk("every value carries a slug path, not a display string",
|
||||||
|
all(re.fullmatch(r"[A-Z0-9-]+(/[A-Z0-9-]+)*", c) for c in codes), codes)
|
||||||
|
chk("...with the hierarchy in the path, which is what CR-018 groups by",
|
||||||
|
"PROBE-BUILDING-ONE/PROBE-LEVEL-1/PROBE-SECTOR-A" in codes, codes)
|
||||||
|
nodes = api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]
|
||||||
|
target = [n for n in nodes if n["path"] == "PROBE-BUILDING-ONE/PROBE-LEVEL-2"][0]
|
||||||
|
renamed = api(page, "PATCH", "/api/projects/projA/locations/" + target["id"],
|
||||||
|
{"name": "Renamed Level"})
|
||||||
|
chk("renaming a value succeeds", renamed["status"] == 200, renamed)
|
||||||
|
chk("...changes the label", renamed["body"]["name"] == "Renamed Level", renamed["body"])
|
||||||
|
chk("...and leaves the code EXACTLY as it was",
|
||||||
|
renamed["body"]["path"] == target["path"] and renamed["body"]["code"] == target["code"],
|
||||||
|
[target["path"], renamed["body"]["path"]])
|
||||||
|
kids = [n for n in api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]
|
||||||
|
if n["path"].startswith(target["path"] + "/")]
|
||||||
|
chk("...and its children's codes with it, so nothing beneath is orphaned",
|
||||||
|
kids and all(k["path"].startswith("PROBE-BUILDING-ONE/PROBE-LEVEL-2/") for k in kids),
|
||||||
|
[k["path"] for k in kids])
|
||||||
|
|
||||||
|
print("\n2. duplicates are reported, not merged")
|
||||||
|
page.eval("document.getElementById('loc-paste').value = %s" % json.dumps(MESSY))
|
||||||
|
page.eval("document.getElementById('loc-import-btn').click()")
|
||||||
|
settle(1.6)
|
||||||
|
report = page.eval("(document.getElementById('loc-report')||{}).textContent||''")
|
||||||
|
chk("the report names duplicates as duplicates", "Duplicates, not merged" in report,
|
||||||
|
repr(report[:200]))
|
||||||
|
chk("...one already in the project, one repeated inside the file",
|
||||||
|
"already in this project" in report and "already on line" in report, repr(report[:300]))
|
||||||
|
chk("...and rejected rows separately", "Rejected" in report, repr(report[:200]))
|
||||||
|
chk("...naming the reason for each", "no building" in report and "no floor above it" in report,
|
||||||
|
repr(report[:400]))
|
||||||
|
chk("...and the line number, so it can be found in the file",
|
||||||
|
re.search(r"row \d+", report) is not None, repr(report[:200]))
|
||||||
|
chk("the report interrupts when it lost rows (T4.5)",
|
||||||
|
page.eval("document.getElementById('loc-report').getAttribute('role')") == "alert")
|
||||||
|
after = rows_in_list(page)
|
||||||
|
chk("a duplicate row created nothing", len(after) == 9 + 3,
|
||||||
|
[r["code"] for r in after])
|
||||||
|
chk("...while the genuinely new rows in the same file did import",
|
||||||
|
"PROBE-BUILDING-THREE/PROBE-LEVEL-1/PROBE-SECTOR-A" in [r["code"] for r in after],
|
||||||
|
[r["code"] for r in after])
|
||||||
|
|
||||||
|
print("\n1b. a CSV file goes through the same parser as a paste")
|
||||||
|
page.eval("document.getElementById('loc-paste').value = ''")
|
||||||
|
page.eval("""(() => {
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
dt.items.add(new File([%s], 'probe-locations.csv', {type: 'text/csv'}));
|
||||||
|
const el = document.getElementById('loc-file');
|
||||||
|
el.files = dt.files;
|
||||||
|
el.dispatchEvent(new Event('change', {bubbles: true}));
|
||||||
|
return true;
|
||||||
|
})()""" % json.dumps("Probe Building Four,Probe Level 1,Probe Sector A\n"))
|
||||||
|
for _ in range(20):
|
||||||
|
if page.eval("!!document.getElementById('loc-paste').value"):
|
||||||
|
break
|
||||||
|
time.sleep(0.2)
|
||||||
|
chk("choosing a file fills the same box a paste does",
|
||||||
|
"Probe Building Four" in page.eval("document.getElementById('loc-paste').value"),
|
||||||
|
page.eval("document.getElementById('loc-paste').value")[:80])
|
||||||
|
chk("...and says which file it read",
|
||||||
|
"probe-locations.csv" in page.eval(
|
||||||
|
"(document.getElementById('loc-report')||{}).textContent||''"))
|
||||||
|
page.eval("document.getElementById('loc-import-btn').click()")
|
||||||
|
settle(1.6)
|
||||||
|
chk("...and importing it works the same way",
|
||||||
|
"PROBE-BUILDING-FOUR/PROBE-LEVEL-1/PROBE-SECTOR-A"
|
||||||
|
in [r["code"] for r in rows_in_list(page)],
|
||||||
|
[r["code"] for r in rows_in_list(page)])
|
||||||
|
|
||||||
|
print("\n3. the list is editable after import")
|
||||||
|
page.eval("""(() => {
|
||||||
|
document.getElementById('loc-add-parent').value = '';
|
||||||
|
const n = document.getElementById('loc-add-name');
|
||||||
|
n.value = 'Probe Building Five';
|
||||||
|
document.getElementById('loc-add-btn').click();
|
||||||
|
return true;
|
||||||
|
})()""")
|
||||||
|
settle(1.4)
|
||||||
|
chk("a value can be added by hand",
|
||||||
|
"PROBE-BUILDING-FIVE" in [r["code"] for r in rows_in_list(page)],
|
||||||
|
[r["code"] for r in rows_in_list(page)])
|
||||||
|
page.eval("""(() => {
|
||||||
|
const n = document.getElementById('loc-add-name');
|
||||||
|
n.value = ' ';
|
||||||
|
document.getElementById('loc-add-btn').click();
|
||||||
|
return true;
|
||||||
|
})()""")
|
||||||
|
settle(0.6)
|
||||||
|
chk("...an empty one is refused at the field, not in a dialog",
|
||||||
|
bool((page.eval("(document.getElementById('loc-add-err')||{}).textContent||''")).strip()),
|
||||||
|
page.eval("(document.getElementById('loc-add-err')||{}).textContent||''"))
|
||||||
|
chk("...announced through a live region",
|
||||||
|
page.eval("document.getElementById('loc-add-err').getAttribute('role')") == "alert")
|
||||||
|
dup = api(page, "POST", "/api/projects/projA/locations",
|
||||||
|
{"level": "building", "name": "Probe Building Five"})
|
||||||
|
chk("...and adding one that already exists is refused with a reason",
|
||||||
|
dup["status"] == 409 and "already" in str(dup["body"].get("detail", "")).lower(), dup)
|
||||||
|
orphan = api(page, "POST", "/api/projects/projA/locations",
|
||||||
|
{"level": "sector", "name": "Probe Loose Sector"})
|
||||||
|
chk("...a sector with nothing above it is refused",
|
||||||
|
orphan["status"] == 400 and "floor" in str(orphan["body"].get("detail", "")).lower(),
|
||||||
|
orphan)
|
||||||
|
|
||||||
|
print("\n4. deactivating hides a value without breaking what references it")
|
||||||
|
nodes = api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]
|
||||||
|
floor = [n for n in nodes if n["path"] == "PROBE-BUILDING-ONE/PROBE-LEVEL-1"][0]
|
||||||
|
off = api(page, "PATCH", "/api/projects/projA/locations/" + floor["id"], {"active": False})
|
||||||
|
chk("a value can be deactivated", off["status"] == 200 and off["body"]["active"] is False, off)
|
||||||
|
|
||||||
|
live = api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]
|
||||||
|
live_paths = [n["path"] for n in live]
|
||||||
|
chk("...and disappears from the list new work packages choose from",
|
||||||
|
floor["path"] not in live_paths, live_paths)
|
||||||
|
chk("...taking its sectors with it, so nothing offers a path to nowhere",
|
||||||
|
not [p for p in live_paths if p.startswith(floor["path"] + "/")], live_paths)
|
||||||
|
chk("...while its building stays offered", "PROBE-BUILDING-ONE" in live_paths, live_paths)
|
||||||
|
|
||||||
|
all_nodes = api(page, "GET",
|
||||||
|
"/api/projects/projA/locations?include_inactive=true")["body"]["nodes"]
|
||||||
|
kept = [n for n in all_nodes if n["path"] == floor["path"]]
|
||||||
|
chk("...the row itself is RETAINED, not deleted", len(kept) == 1, [n["path"] for n in all_nodes])
|
||||||
|
chk("...so a work package already referencing it still resolves its label",
|
||||||
|
kept and kept[0]["name"] == floor["name"], kept)
|
||||||
|
chk("...and there is no DELETE route at all to lose it by",
|
||||||
|
api(page, "DELETE", "/api/projects/projA/locations/" + floor["id"])["status"] == 405,
|
||||||
|
api(page, "DELETE", "/api/projects/projA/locations/" + floor["id"]))
|
||||||
|
|
||||||
|
open_step11(page, base, tok)
|
||||||
|
shown = rows_in_list(page)
|
||||||
|
chk("the wizard still shows the deactivated value, so it can be brought back",
|
||||||
|
any(r["code"] == floor["path"] and r["off"] for r in shown),
|
||||||
|
[(r["code"], r["off"]) for r in shown])
|
||||||
|
page.eval("""(() => {
|
||||||
|
const row = [...document.querySelectorAll('#loc-list .loc-row')]
|
||||||
|
.find(r => (r.querySelector('.loc-code')||{}).textContent === %s);
|
||||||
|
const box = row.querySelector('.loc-active');
|
||||||
|
box.checked = true;
|
||||||
|
box.dispatchEvent(new Event('change', {bubbles: true}));
|
||||||
|
return true;
|
||||||
|
})()""" % json.dumps(floor["path"]))
|
||||||
|
settle(1.4)
|
||||||
|
back = [n["path"] for n in api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]]
|
||||||
|
chk("ticking it puts it back in use", floor["path"] in back, back)
|
||||||
|
chk("...on the SAME row, so nothing that referenced it was orphaned",
|
||||||
|
[n for n in api(page, "GET",
|
||||||
|
"/api/projects/projA/locations?include_inactive=true")["body"]["nodes"]
|
||||||
|
if n["path"] == floor["path"]][0]["id"] == floor["id"])
|
||||||
|
|
||||||
|
print("\n re-importing a deactivated value brings it back rather than duplicating it")
|
||||||
|
all_now = api(page, "GET",
|
||||||
|
"/api/projects/projA/locations?include_inactive=true")["body"]["nodes"]
|
||||||
|
live_now = [n["path"] for n in
|
||||||
|
api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]]
|
||||||
|
sec = [n for n in all_now if n["path"].endswith("/PROBE-SECTOR-B")][0]
|
||||||
|
# Deliberate asymmetry, pinned here so nobody "fixes" it into a surprise:
|
||||||
|
# deactivating a floor takes its sectors down with it, but ticking the floor
|
||||||
|
# back on does NOT resurrect them. A sector may have been switched off for its
|
||||||
|
# own reasons, and silently bringing it back would undo a decision nobody made
|
||||||
|
# twice. They stay listed, struck through, one tick away.
|
||||||
|
chk("reactivating a parent does not silently resurrect its children",
|
||||||
|
sec["path"] not in live_now, [sec["path"], live_now])
|
||||||
|
chk("...and they are still listed, so they can be brought back deliberately",
|
||||||
|
sec["active"] is False)
|
||||||
|
api(page, "PATCH", "/api/projects/projA/locations/" + sec["id"], {"active": False})
|
||||||
|
again = api(page, "POST", "/api/projects/projA/locations/import",
|
||||||
|
{"text": "Probe Building One,Probe Level 1,Probe Sector B"})
|
||||||
|
chk("the import reports it as reactivated, not created or duplicate",
|
||||||
|
len(again["body"]["reactivated"]) == 1 and not again["body"]["created"], again["body"])
|
||||||
|
same = [n for n in api(page, "GET",
|
||||||
|
"/api/projects/projA/locations?include_inactive=true")["body"]["nodes"]
|
||||||
|
if n["path"] == sec["path"]]
|
||||||
|
chk("...and reuses the same row", len(same) == 1 and same[0]["id"] == sec["id"], same)
|
||||||
|
|
||||||
|
print("\n6. no guessed real-world floor names, and no localStorage behind the list")
|
||||||
|
chk("no native dialog was opened anywhere in this flow",
|
||||||
|
not json.loads(page.eval("JSON.stringify(window.__dialogs||[])")),
|
||||||
|
page.eval("JSON.stringify(window.__dialogs||[])"))
|
||||||
|
src_js = open(os.path.join(ROOT, "html", "work-package-suite-app.js"), encoding="utf-8").read()
|
||||||
|
start = src_js.find("// ── STEP 11: LOCATION LIST")
|
||||||
|
end = src_js.find("// ── STEP GATES", start) if start >= 0 else -1
|
||||||
|
block = src_js[start:end] if start >= 0 and end > start else ""
|
||||||
|
chk("the locations code exists as its own bounded block", bool(block), [start, end])
|
||||||
|
# Comments stripped first: this block QUOTES CLAUDE.md's rule about localStorage,
|
||||||
|
# and a probe that failed on the sentence forbidding the thing rather than on the
|
||||||
|
# thing would be the least useful possible false positive.
|
||||||
|
code_only = "\n".join(ln for ln in block.splitlines()
|
||||||
|
if not ln.strip().startswith(("//", "*", "/*")))
|
||||||
|
chk("...and never touches localStorage", "localStorage" not in code_only,
|
||||||
|
[ln for ln in code_only.splitlines() if "localStorage" in ln][:3])
|
||||||
|
chk("...reaching the server for every read and write",
|
||||||
|
block.count("fetch(locApi") >= 4, block.count("fetch(locApi"))
|
||||||
|
|
||||||
|
# The B100 floor/area list has not been supplied (IMPLEMENTATION.md section 8),
|
||||||
|
# so any of these appearing as a location value would be a guess presented as
|
||||||
|
# data. Checked over the whole tree, not only the file this task touched.
|
||||||
|
GUESSES = ("B100", "1P", "2P", "Fab 7", "Level 2 chase")
|
||||||
|
hits = []
|
||||||
|
for folder in ("html", "server"):
|
||||||
|
for name in sorted(os.listdir(os.path.join(ROOT, folder))):
|
||||||
|
if not name.endswith((".js", ".html", ".py", ".css")):
|
||||||
|
continue
|
||||||
|
text = open(os.path.join(ROOT, folder, name), encoding="utf-8").read()
|
||||||
|
# Only where it would be a location VALUE — the review quotes "1P" in
|
||||||
|
# prose all over the place, and a comment is not a hardcoded floor.
|
||||||
|
for m in re.finditer(r"(building|floor|sector|location)\w*\s*[:=]\s*['\"]([^'\"]{1,40})",
|
||||||
|
text, re.I):
|
||||||
|
if any(g.lower() in m.group(2).lower() for g in GUESSES):
|
||||||
|
hits.append("%s/%s: %s" % (folder, name, m.group(0)[:60]))
|
||||||
|
chk("no guessed real-world floor or sector name is assigned anywhere", not hits, hits[:4])
|
||||||
|
sample = re.search(r"const LOCATION_SAMPLE = \[(.*?)\]", src_js, re.S)
|
||||||
|
chk("the seeded sample values are obviously fake", bool(sample)
|
||||||
|
and all("Sample" in ln for ln in re.findall(r"'([^']+)'", sample.group(1))),
|
||||||
|
sample.group(1)[:200] if sample else None)
|
||||||
|
|
||||||
|
print("\n both widths")
|
||||||
|
for w, label in ((390, "390px"), (1440, "1440px")):
|
||||||
|
page.viewport(w, 900, mobile=(w == 390))
|
||||||
|
open_step11(page, base, tok)
|
||||||
|
chk("%s: the location step renders" % label,
|
||||||
|
page.eval("document.querySelectorAll('#loc-list .loc-row').length") > 0)
|
||||||
|
chk("%s: the page does not scroll sideways" % label,
|
||||||
|
page.eval("document.documentElement.scrollWidth <= window.innerWidth + 1"),
|
||||||
|
page.eval("[document.documentElement.scrollWidth, window.innerWidth]"))
|
||||||
|
page.viewport(1400, 1000)
|
||||||
|
|
||||||
|
|
||||||
|
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-locations-")
|
||||||
|
db_path = os.path.join(tmpdir, "check.db")
|
||||||
|
server = None
|
||||||
|
try:
|
||||||
|
tok = seed(db_path)
|
||||||
|
port = cdp.free_port()
|
||||||
|
base = "http://127.0.0.1:%d" % port
|
||||||
|
server = start_server(port, db_path)
|
||||||
|
if server is None:
|
||||||
|
print("the test server would not start.")
|
||||||
|
return 2
|
||||||
|
print("\nLocation taxonomy — CR-005\nTarget: %s" % base)
|
||||||
|
|
||||||
|
browser = cdp.Browser(exe)
|
||||||
|
page = browser.page()
|
||||||
|
try:
|
||||||
|
run(page, base, tok)
|
||||||
|
finally:
|
||||||
|
page.close()
|
||||||
|
browser.close()
|
||||||
|
finally:
|
||||||
|
if server:
|
||||||
|
server.kill()
|
||||||
|
try:
|
||||||
|
server.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from server.db import engine
|
||||||
|
engine.dispose()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
import shutil
|
||||||
|
for _ in range(10):
|
||||||
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||||
|
if not os.path.exists(tmpdir):
|
||||||
|
break
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
total = len(_PASS) + len(_FAIL)
|
||||||
|
print("\n%s\n%d/%d checks passed." % ("-" * 54, len(_PASS), total))
|
||||||
|
if _FAIL:
|
||||||
|
for f in _FAIL:
|
||||||
|
print(" - " + f)
|
||||||
|
return 1
|
||||||
|
print("\nResult: " + _c("ALL PASS — codes not labels, nothing merged, nothing deleted.", "32") + "\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -9,7 +9,7 @@ detached from the control it described.
|
|||||||
Every one of T5.1's done-whens is checked here, because nothing that already
|
Every one of T5.1's done-whens is checked here, because nothing that already
|
||||||
existed could check any of them:
|
existed could check any of them:
|
||||||
|
|
||||||
1. all ten steps are <button> elements
|
1. every step is a <button> element (ten until T5.4 appended Locations)
|
||||||
2. the rail is operable by tab, arrow keys, Home/End, Enter and Space
|
2. the rail is operable by tab, arrow keys, Home/End, Enter and Space
|
||||||
3. the current step is exposed with aria-current
|
3. the current step is exposed with aria-current
|
||||||
4. complete / current / unavailable are distinguishable WITHOUT colour
|
4. complete / current / unavailable are distinguishable WITHOUT colour
|
||||||
@@ -51,6 +51,10 @@ HTML_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__
|
|||||||
# Wave 0 recorded 12. T5.1 must take ten of them.
|
# Wave 0 recorded 12. T5.1 must take ten of them.
|
||||||
BASELINE_DIV_ONCLICK = 12
|
BASELINE_DIV_ONCLICK = 12
|
||||||
|
|
||||||
|
# Ten until T5.4 (CR-005) appended Locations. Named rather than repeated, so the
|
||||||
|
# next step to be added moves one number instead of eight assertions.
|
||||||
|
STEP_COUNT = 11
|
||||||
|
|
||||||
# Swallow the wizard's remaining native dialogs and record that they fired. The
|
# Swallow the wizard's remaining native dialogs and record that they fired. The
|
||||||
# wizard still has them until T5.8; this proves the RAIL never reaches one.
|
# wizard still has them until T5.8; this proves the RAIL never reaches one.
|
||||||
STUB = """
|
STUB = """
|
||||||
@@ -196,10 +200,11 @@ def run(page, base, tok):
|
|||||||
page.eval(STUB)
|
page.eval(STUB)
|
||||||
|
|
||||||
# ── 1. every step is a real button ────────────────────────────────────────
|
# ── 1. every step is a real button ────────────────────────────────────────
|
||||||
print("\n1. all ten steps are <button> elements")
|
print("\n1. every step is a <button> element")
|
||||||
open_wizard()
|
open_wizard()
|
||||||
rows = rail(page)
|
rows = rail(page)
|
||||||
chk("the rail renders ten steps", len(rows) == 10, "found %d" % len(rows))
|
chk("the rail renders %d steps" % STEP_COUNT, len(rows) == STEP_COUNT,
|
||||||
|
"found %d" % len(rows))
|
||||||
chk("...every one of them a <button>", rows and all(r["tag"] == "BUTTON" for r in rows),
|
chk("...every one of them a <button>", rows and all(r["tag"] == "BUTTON" for r in rows),
|
||||||
[r["tag"] for r in rows])
|
[r["tag"] for r in rows])
|
||||||
chk('...with type="button", so none of them submits anything',
|
chk('...with type="button", so none of them submits anything',
|
||||||
@@ -218,8 +223,8 @@ def run(page, base, tok):
|
|||||||
chk("no #total-steps element", page.eval("!document.getElementById('total-steps')"))
|
chk("no #total-steps element", page.eval("!document.getElementById('total-steps')"))
|
||||||
chk("no .step-counter anywhere on the page",
|
chk("no .step-counter anywhere on the page",
|
||||||
page.eval("!document.querySelector('.step-counter')"))
|
page.eval("!document.querySelector('.step-counter')"))
|
||||||
chk("...and no bare 'N / 10' left in the app bar",
|
chk("...and no bare 'N / %d' left in the app bar" % STEP_COUNT,
|
||||||
not re.search(r"\b\d+\s*/\s*10\b",
|
not re.search(r"\b\d+\s*/\s*%d\b" % STEP_COUNT,
|
||||||
page.eval("(document.querySelector('.header')||{}).textContent||''")),
|
page.eval("(document.querySelector('.header')||{}).textContent||''")),
|
||||||
page.eval("(document.querySelector('.header')||{}).textContent||''")[:120])
|
page.eval("(document.querySelector('.header')||{}).textContent||''")[:120])
|
||||||
|
|
||||||
@@ -246,7 +251,8 @@ def run(page, base, tok):
|
|||||||
# a fresh load and everything ahead of it is genuinely out of reach.
|
# a fresh load and everything ahead of it is genuinely out of reach.
|
||||||
locked = [r for r in rows if r["ariaDisabled"] == "true"]
|
locked = [r for r in rows if r["ariaDisabled"] == "true"]
|
||||||
chk("with step 1 incomplete, steps 2-10 are unavailable",
|
chk("with step 1 incomplete, steps 2-10 are unavailable",
|
||||||
[r["step"] for r in locked] == list(range(2, 11)), [r["step"] for r in locked])
|
[r["step"] for r in locked] == list(range(2, STEP_COUNT + 1)),
|
||||||
|
[r["step"] for r in locked])
|
||||||
chk("...each saying 'Locked' in words, not only in colour",
|
chk("...each saying 'Locked' in words, not only in colour",
|
||||||
locked and all(r["state"] == "Locked" for r in locked),
|
locked and all(r["state"] == "Locked" for r in locked),
|
||||||
list({r["state"] for r in locked}))
|
list({r["state"] for r in locked}))
|
||||||
@@ -300,7 +306,7 @@ def run(page, base, tok):
|
|||||||
page.eval("(document.activeElement.dataset||{}).step"))
|
page.eval("(document.activeElement.dataset||{}).step"))
|
||||||
press(page, "End")
|
press(page, "End")
|
||||||
chk("End jumps to the last step",
|
chk("End jumps to the last step",
|
||||||
page.eval("(document.activeElement.dataset||{}).step") == "10",
|
page.eval("(document.activeElement.dataset||{}).step") == str(STEP_COUNT),
|
||||||
page.eval("(document.activeElement.dataset||{}).step"))
|
page.eval("(document.activeElement.dataset||{}).step"))
|
||||||
press(page, "Home")
|
press(page, "Home")
|
||||||
chk("Home jumps to the first",
|
chk("Home jumps to the first",
|
||||||
@@ -332,7 +338,7 @@ def run(page, base, tok):
|
|||||||
# have just filled in is worse than the strip it replaced.
|
# have just filled in is worse than the strip it replaced.
|
||||||
rows = rail(page)
|
rows = rail(page)
|
||||||
chk("emptying a required field re-locks the steps ahead, with no navigation",
|
chk("emptying a required field re-locks the steps ahead, with no navigation",
|
||||||
[r["step"] for r in rows if r["ariaDisabled"] == "true"] == list(range(2, 11)),
|
[r["step"] for r in rows if r["ariaDisabled"] == "true"] == list(range(2, STEP_COUNT + 1)),
|
||||||
[r["step"] for r in rows if r["ariaDisabled"] == "true"])
|
[r["step"] for r in rows if r["ariaDisabled"] == "true"])
|
||||||
chk("...and the guard refuses the same move",
|
chk("...and the guard refuses the same move",
|
||||||
page.eval("(() => { const n = currentStep; goToStep(4); return currentStep === n; })()"))
|
page.eval("(() => { const n = currentStep; goToStep(4); return currentStep === n; })()"))
|
||||||
@@ -426,9 +432,9 @@ def run(page, base, tok):
|
|||||||
page.eval("(document.getElementById('step-rail-toggle')||{}).textContent||''"))
|
page.eval("(document.getElementById('step-rail-toggle')||{}).textContent||''"))
|
||||||
page.click("#step-rail-toggle")
|
page.click("#step-rail-toggle")
|
||||||
settle(page, 0.5)
|
settle(page, 0.5)
|
||||||
chk("tapping it reveals all ten steps",
|
chk("tapping it reveals every step",
|
||||||
page.eval("[...document.querySelectorAll('#step-rail-list .step-btn')]"
|
page.eval("[...document.querySelectorAll('#step-rail-list .step-btn')]"
|
||||||
".filter(b => b.getBoundingClientRect().height > 0).length") == 10,
|
".filter(b => b.getBoundingClientRect().height > 0).length") == STEP_COUNT,
|
||||||
page.eval("[...document.querySelectorAll('#step-rail-list .step-btn')]"
|
page.eval("[...document.querySelectorAll('#step-rail-list .step-btn')]"
|
||||||
".filter(b => b.getBoundingClientRect().height > 0).length"))
|
".filter(b => b.getBoundingClientRect().height > 0).length"))
|
||||||
chk("...and flips aria-expanded", page.eval(
|
chk("...and flips aria-expanded", page.eval(
|
||||||
|
|||||||
Reference in New Issue
Block a user