Compare commits
17 Commits
feat/wp-su
...
17cabbd032
| Author | SHA1 | Date | |
|---|---|---|---|
| 17cabbd032 | |||
| 222c0b1c29 | |||
| e31234beef | |||
| 6057d05b98 | |||
| 64eac0cbbb | |||
| 8f280d4bd1 | |||
| a8e28bf3ab | |||
| 29c4cd313e | |||
| 8fe7b25cd8 | |||
| 560f0cb3cc | |||
| 24f60151e5 | |||
| 031dc6b995 | |||
| 0f28a27441 | |||
| 16afc56c0a | |||
| 8efe624d5d | |||
| 24476c86a6 | |||
| cc761c8f7d |
@@ -93,7 +93,7 @@ checks. In addition, for any task touching the frontend:
|
||||
2. Exercise the affected flow at **390px** and at **1440px**. Field View at 390px is the
|
||||
gloved-hands surface and is where the worst rendering was found.
|
||||
3. Capture before and after screenshots into the PR.
|
||||
4. Run the existing smoke test. It signs in; `server/seed_demo.py` does not, which is S13.
|
||||
4. Run the existing smoke test. It signs in, and so does `server/seed_demo.py` (S13, fixed at T1.6 - this line said otherwise until Aug 20 2026, a stale record).
|
||||
|
||||
If a done-when check cannot be verified, do not mark the task complete. Say which check
|
||||
failed and why.
|
||||
|
||||
@@ -12,6 +12,7 @@ Close an entry by deleting it in the same commit that fixes it.
|
||||
|---|-------|----------|--------|--------|
|
||||
| 1 | XSS via SOP discipline names in the WP creator | Medium (internal), High if externally reachable | 2026-08-05 | Open |
|
||||
| 2 | Archived projects: the two big apps don't grey out their own controls | Low | 2026-08-05 | Open |
|
||||
| 3 | Export is not one merged PDF; drawings ride along as a list | Low | 2026-08-20 | Open — decided |
|
||||
|
||||
---
|
||||
|
||||
@@ -161,3 +162,35 @@ save/issue controls, or add a boot check in each app that disables them and show
|
||||
read-only notice inline. Decide separately how the embedded creator
|
||||
(`wp-creation-index.html`) surfaces it, since it runs in an iframe where the shared
|
||||
app bar — and therefore the banner — is deliberately skipped.
|
||||
|
||||
---
|
||||
|
||||
## 3. Export is not one merged PDF; drawings ride along as a list
|
||||
|
||||
**Files:** `html/wp-creation-app.js` (the T9.1 export walk), `CR-008`
|
||||
**Decided:** 2026-08-20, by Nick — "add this to known issues."
|
||||
|
||||
### What is wrong
|
||||
|
||||
CR-008 asked for the work package "as one document." What shipped (T9.1)
|
||||
renders every section inline — including images — and lists PDF drawing
|
||||
attachments with links, rather than merging their pages into a single PDF.
|
||||
|
||||
### What it costs
|
||||
|
||||
A crew printing the package gets the form and the inline images in one pass,
|
||||
but linked PDF drawings are separate opens/prints. For field hand-offs that
|
||||
want literally one file, someone stitches it manually.
|
||||
|
||||
### Why it is still open
|
||||
|
||||
Real PDF merging needs either a server-side PDF library (a new dependency and
|
||||
a render pipeline for arbitrary uploaded PDFs) or a client-side one (heavy,
|
||||
and the creator is deliberately dependency-free). The recommendation made at
|
||||
T9.1 — inline images + listed PDFs — was accepted as the shipped behaviour.
|
||||
|
||||
### What closing it takes
|
||||
|
||||
A server-side merge endpoint (e.g. pypdf) that concatenates the rendered
|
||||
package with each attached PDF, streamed back as one download; plus a size
|
||||
ceiling consistent with D8's upload limits. One task, one new dependency.
|
||||
|
||||
@@ -40,7 +40,7 @@ name the probe that re-checks the item on every run.
|
||||
| S6 | built | T9.3 (`icon_check` 5) | one monochrome system, mapped in `tokens.md` |
|
||||
| S7 | built | T9.4 (`sample_check` 10) | one affordance, confirmed, fenced — verified against a real project |
|
||||
| S8 | built | T9.5 (`helptip_check` 13) | 20 badges → buttons; **closed BL-001** |
|
||||
| S13 | open | — | `seed_demo.py` still does not sign in; the smoke test does. Carried to the next revision |
|
||||
| S13 | built | T1.6 (re-verified 2026-08-20) | this row said "open / does not sign in" until Aug 20 - a records error: T1.6 fixed it in wave 1 (it reuses smoketest's login) and the wave-1 exit box was simply never ticked. Verified live: sign-in, seed, `--clean` |
|
||||
| A1 | preserved | T7.3 | `confirmEarlyRelease()` by name; async now, same contract |
|
||||
| A2 | built | T7.4 (`warning_check` 17) | one warning; the count on the sticky rail |
|
||||
| A3–A5, A7 | built | waves 1–6 | localization re-verified through T7.10's admin edits (`cards_check`) |
|
||||
@@ -105,7 +105,6 @@ name the probe that re-checks the item on every run.
|
||||
aggregated, placement needs Nick.
|
||||
- **BL-024** — 21 native dialogs on admin/users/launcher; the T7.9 kit is
|
||||
ready for them.
|
||||
- **S13** — `seed_demo.py` sign-in.
|
||||
- Product questions raised in commit messages, awaiting answers: hold
|
||||
reachable from Draft/Scheduled (T7.3); CR-014 email body content (T7.6);
|
||||
merged-PDF export (T9.1).
|
||||
|
||||
@@ -247,7 +247,7 @@ Wave 4 added three more, each written because its task's done-when could not be
|
||||
anything that already existed:
|
||||
|
||||
```bash
|
||||
python tests/aggregates_check.py # B4 — do the counts come from the server? 16 checks
|
||||
python tests/aggregates_check.py # B4 — do the counts come from the server? 17 checks
|
||||
python tests/url_state_check.py # S3 — does the app's state have an address? 23 checks
|
||||
python tests/autosave_check.py # S2/B5 — does unsaved work survive? 34 checks
|
||||
python tests/a11y_check.py # S10/S11/S12 — announce, legible, focus 22 checks
|
||||
@@ -280,7 +280,7 @@ python tests/form_structure_check.py # F6/D3 - rail, disclosure, one open sectio
|
||||
python tests/hold_check.py # CR-015/A1/D4 - the hold clears, gates hold 50 checks
|
||||
python tests/warning_check.py # A2 - one warning, a badge from anywhere 17 checks
|
||||
python tests/triage_check.py # A6 - the sidebar answers the stand-up 16 checks
|
||||
python tests/qa_gate_check.py # CR-014/D2/D9/D10 - QA gate + capture sink 40 checks
|
||||
python tests/qa_gate_check.py # CR-014/D2/D9/D10 - QA gate + capture sink 41 checks
|
||||
python tests/files_check.py # CR-007/D8 - drawings upload + real offline 36 checks
|
||||
python tests/sticky_bar_check.py # B6 - save reachable on every wizard step 12 checks
|
||||
python tests/usage_check.py # D5 - one analytics core, admin report 15 checks
|
||||
@@ -292,7 +292,7 @@ Wave 8 adds these:
|
||||
```bash
|
||||
python tests/kitting_check.py # CR-009/010/012 - statuses, owner, delivery 26 checks
|
||||
python tests/kitting_notify_check.py # CR-011 - kitting mail, coalesced, gated 17 checks
|
||||
python tests/materials_check.py # D6 - material list, the CR-005 pattern 17 checks
|
||||
python tests/materials_check.py # D6 - material list, the CR-005 pattern 20 checks
|
||||
python tests/mreq_check.py # CR-013 - lightweight request, end to end 19 checks
|
||||
```
|
||||
|
||||
@@ -312,6 +312,8 @@ The August 20 integration adds:
|
||||
|
||||
```bash
|
||||
python tests/assets_check.py # D11 - Micron picker: read-only, degrades 31 checks
|
||||
python tests/critical_reopen_check.py # BL-021 - on-hold mail reaches PM + CM 11 checks
|
||||
python tests/console_dialogs_check.py # BL-024 - consoles/launcher: 21 natives -> 0 17 checks
|
||||
```
|
||||
|
||||
**Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live
|
||||
|
||||
@@ -447,7 +447,7 @@ deliberately deferred.
|
||||
what is left is `field.html`'s `.fld-search`, and `T9.5` should re-measure that one the
|
||||
same way rather than inheriting this entry's wording.
|
||||
|
||||
### BL-020 — Switching from the SOP wizard to the creator can now prompt to leave
|
||||
### BL-020 — CLOSED (decided 2026-08-20: keep it) — the wizard-exit prompt stays
|
||||
|
||||
- **Found during:** T7.1
|
||||
- **Where:** `html/wp-autosave.js:96` (the `beforeunload` guard), reached from the
|
||||
@@ -474,7 +474,7 @@ deliberately deferred.
|
||||
kept, `T7.2`'s side navigation is the place to make saving obvious enough that
|
||||
the prompt stops being a surprise.
|
||||
|
||||
### BL-021 — `project_sop_team()` reads a path `pushSOP` never writes
|
||||
### BL-021 — CLOSED 2026-08-20 (`project_sop_team()` reads nested-first; `critical_reopen_check` 11, sink-verified)
|
||||
|
||||
- **Found during:** T7.6
|
||||
- **Where:** `server/app.py`, `project_sop_team()`
|
||||
@@ -491,7 +491,7 @@ deliberately deferred.
|
||||
- **Suggested wave or follow-up:** wave 9 backlog sweep (`T9.9`), verified with
|
||||
the `tests/qa_gate_check.py` sink pattern.
|
||||
|
||||
### BL-022 — F6's "roughly two screen heights": 2.17 against a strict 2.0
|
||||
### BL-022 — CLOSED 2026-08-20 (strict 2.0; the chrome compressed to 1,784px = 1.98 screens; form_structure_check 51/51 for the first time)
|
||||
|
||||
- **Found during:** T7.2, re-measured at the wave 7 exit
|
||||
- **Where:** `html/wp-creation-index.html` page chrome; `tests/form_structure_check.py`
|
||||
@@ -510,7 +510,7 @@ deliberately deferred.
|
||||
becomes a small T9 task. The strict check stays red so the question cannot be
|
||||
forgotten.
|
||||
|
||||
### BL-023 — Productivity factor: actual against estimated hours
|
||||
### BL-023 — CLOSED into D12 (decided 2026-08-20: the dashboard) — see decisions-2026-08-20.md
|
||||
|
||||
- **Found during:** T9.2 (logged as that task's done-when requires)
|
||||
- **Where:** future — dashboard / rollups
|
||||
@@ -518,13 +518,14 @@ deliberately deferred.
|
||||
hours exist on every package; nothing yet compares them. A productivity
|
||||
factor (actual ÷ estimated, rolled up by discipline / building / type the way
|
||||
CR-018 rolls cost) is the measurement Marlena's tracking exists to enable.
|
||||
The rollup endpoints (`/api/wps/metrics`, `/api/projects/{id}/summary`)
|
||||
already carry both sums, so this is a presentation task, not a data one.
|
||||
`/api/wps/metrics` already carries both sums, so this is a presentation
|
||||
task, not a data one. (Corrected at D12: the entry originally credited
|
||||
`/api/projects/{id}/summary` too, which carries no hours at all.)
|
||||
- **Why not now:** new scope — needs its own item id per the working rules, and
|
||||
a product conversation about where it displays and who reads it.
|
||||
- **Suggested wave or follow-up:** next revision; needs Nick for placement.
|
||||
|
||||
### BL-024 — 21 native dialogs remain on the operator consoles and the launcher
|
||||
### BL-024 — CLOSED 2026-08-20 (wp-dialog.js, the T7.9 kit shared; 21 -> 0; `console_dialogs_check` 17)
|
||||
|
||||
- **Found during:** T9.5 (the audit's dialog count)
|
||||
- **Where:** `admin.js` (6), `users.js` (10), `index.html` (5)
|
||||
@@ -537,7 +538,7 @@ deliberately deferred.
|
||||
drive-by CLAUDE.md forbids; the audit's job was to measure and document.
|
||||
- **Suggested wave or follow-up:** next revision, one task, using the T7.9 kit.
|
||||
|
||||
### BL-025 — The second brand blue survives as one rgba focus tint in help.js
|
||||
### BL-025 — CLOSED 2026-08-20 (tint rebased onto THE blue; color_check greps space-free spellings)
|
||||
|
||||
- **Found during:** the 2026-08-20 transparency fix (undefined-token sweep)
|
||||
- **Where:** `help.js`, the help-centre search input's `:focus` rule:
|
||||
@@ -553,3 +554,43 @@ deliberately deferred.
|
||||
entry rather than a drive-by.
|
||||
- **Suggested wave or follow-up:** next housekeeping pass, with the check
|
||||
widened so it cannot recur.
|
||||
|
||||
### BL-026 — No version stamp: "is live current?" cannot be answered from the app
|
||||
|
||||
- **Found during:** the 2026-08-21 outage triage (the question that started it)
|
||||
- **Where:** `Dockerfile` / build, `server/app.py` `/api/health`, admin console
|
||||
- **What:** the app carries no record of what code it is running. `/api/health`
|
||||
returns `{"ok": true}` and nothing identifies the deployed commit, so
|
||||
answering "is the live site on the latest code?" took fingerprinting
|
||||
(probing for files/routes that only exist after certain merges) in the
|
||||
middle of an outage. The fix: bake the git SHA into the image at build time
|
||||
(`ARG GIT_SHA`), return it from `/api/health`
|
||||
(`{"ok": true, "version": "<sha>"}`), and show it on the admin console's
|
||||
diagnostics card. Then currency is one glance against `git log -1`.
|
||||
- **Why not now:** new scope — needs its own item id per the working rules
|
||||
(D13 is the natural next), and it touches the image build, which deserves a
|
||||
deploy alongside someone with host access.
|
||||
- **Suggested wave or follow-up:** next housekeeping pass; ~1 task including a
|
||||
probe check that /api/health carries a version field.
|
||||
|
||||
### BL-027 — Migrations are rehearsed on SQLite only; production is Postgres
|
||||
|
||||
- **Found during:** the 2026-08-21 production outage (D6's `material_items`
|
||||
migration crash-looped the api container)
|
||||
- **Where:** `DEPLOYMENT.md` (the update/deploy steps), `tests/`
|
||||
- **What:** the migration chain is verified end-to-end on scratch SQLite, but
|
||||
production runs Postgres, and the dialects disagree exactly where it hurts:
|
||||
`server_default=sa.text('1')` on a Boolean passed every SQLite rehearsal and
|
||||
was refused by Postgres at deploy (DatatypeMismatch), taking the API down
|
||||
until the table was created by hand. The hotfix (64eac0c) fixed that one
|
||||
instance and pinned the Boolean-default class in `materials_check`; the
|
||||
CLASS of dialect drift is still unguarded. Two cheap layers: (1) a runbook
|
||||
step — render `alembic upgrade --sql` for the postgresql dialect and read it
|
||||
before restarting (offline, needs no live DB; this render would have shown
|
||||
`DEFAULT 1` on a boolean); (2) better, a probe that renders every migration
|
||||
for the postgresql dialect on each run and fails on anything the dialect
|
||||
rejects or on known-bad patterns.
|
||||
- **Why not now:** the outage is resolved and the one known instance is fixed
|
||||
and pinned; the systematic guard is its own small task, not a hotfix rider.
|
||||
- **Suggested wave or follow-up:** next housekeeping pass, paired with BL-026
|
||||
(both are "deploys should be boring" work).
|
||||
|
||||
@@ -53,3 +53,50 @@ Cody's behaviour and moves it onto the R2 idiom:
|
||||
a malformed URL's error text can quote password fragments.
|
||||
- **Unconfigured is not an error.** Local dev and the demo DB run with the picker in
|
||||
manual mode; nothing in the suite requires the catalog to exist.
|
||||
|
||||
---
|
||||
|
||||
# The evening decisions (same day)
|
||||
|
||||
Six answers from Nick, given in one message. Recorded verbatim in intent; each
|
||||
names the item it settles. One new item id is assigned (D12); everything else
|
||||
amends or closes an existing question.
|
||||
|
||||
## The answers
|
||||
|
||||
1. **BL-022 — "strict 2.0."** F6/D3's "roughly two screen heights" means
|
||||
**2.0**, not 2.17. The overage is chrome (~154px: the context bar, the
|
||||
release banner's spacing, header/toolbar padding), so this becomes a build
|
||||
task: compress the chrome without deleting what other items placed
|
||||
deliberately (A2's one-warning banner and the SOP identity strip STAY —
|
||||
they get denser, not removed). `form_structure_check`'s red check flips
|
||||
green by the page actually fitting, not by moving the bar.
|
||||
|
||||
2. **Hold from Draft/Scheduled — "no, leave as is."** The hold branch stays
|
||||
reachable from any status. T7.3's raised question is closed; the shipped
|
||||
behaviour is the decided behaviour.
|
||||
|
||||
3. **CR-014 email bodies — links back to the system; customer context is
|
||||
allowed, confidential documents are not.** The T7.6-era rule ("no customer
|
||||
IP in emails") is refined: naming the customer, the project, the package
|
||||
and where the work happens is fine; what must never be embedded is
|
||||
confidential document CONTENT (drawings, attachments, scope text). Every
|
||||
work-package email carries a deep link back to the package in the system.
|
||||
Build task, sink-verified.
|
||||
|
||||
4. **CR-008 merged-PDF export — known issue, not a build.** The export keeps
|
||||
inline images + listed PDF attachments. Recorded as KNOWN-ISSUES.md §3 so
|
||||
the limitation is a commitment, not a surprise.
|
||||
|
||||
5. **BL-023 → D12 — the productivity factor gets a spot on the dashboard.**
|
||||
Placement delegated ("find a spot on the dashboard"). New item id **D12**:
|
||||
actual ÷ estimated hours, from data the rollup endpoints already carry.
|
||||
|
||||
6. **BL-020 — "keep it."** The unsaved-work prompt on leaving the wizard
|
||||
stays. Closed as decided-keep; no build.
|
||||
|
||||
Plus: **"do what's left on the housekeeping"** — BL-021 (the
|
||||
critical-reopen recipient bug), BL-024 (the 21 console/launcher dialogs onto
|
||||
the shared kit), BL-025 (the last second-blue tint + the widened check), and
|
||||
S13 (seed_demo sign-in) are approved to build now, one commit each, on
|
||||
`feat/wp-suite-r3-housekeeping`.
|
||||
|
||||
@@ -180,6 +180,6 @@ The script authenticates like a client; the server does not get weaker.
|
||||
|
||||
- [ ] `F1`, `F3`, `F4` fully resolved and confirmed against the wave 0 baseline screenshots
|
||||
- [ ] `F2` and `F5` contained, with their real fixes referenced (`T2.2`, `T3.4`)
|
||||
- [ ] `S13` fixed and seeding works
|
||||
- [x] `S13` fixed and seeding works (ticked 2026-08-20: the box was missed at the wave exit; re-verified end to end - sign-in, seed, --clean)
|
||||
- [ ] no new `<div onclick>`, no new raw hex values, no new `alert()` calls introduced
|
||||
- [ ] `F6` untouched — it is a structural problem fixed by section tabs in `T7.2`
|
||||
|
||||
@@ -217,7 +217,8 @@
|
||||
|
||||
<script src="wp-usage.js"></script>
|
||||
<script src="console-util.js"></script>
|
||||
<script src="admin.js"></script>
|
||||
<script src="wp-dialog.js"></script>
|
||||
<script src="admin.js"></script>
|
||||
<!-- The app bar's project switcher reads ProjectData; without this the bar on this
|
||||
page could never show a project and always read "Select a project" (F1). Must
|
||||
parse before wp-chrome.js, which reads it as it mounts. -->
|
||||
|
||||
@@ -146,7 +146,9 @@ async function seedDemo(){
|
||||
snapshot();
|
||||
}
|
||||
async function cleanDemo(){
|
||||
if(!confirm('Delete ALL projects whose number starts with DEMO- or SMOKE- (and their SOPs/WPs via cascade)?')) return;
|
||||
if(!(await wpConfirmDialog({title:'Delete demo data',
|
||||
message:'Delete ALL projects whose number starts with DEMO- or SMOKE- (and their SOPs/WPs via cascade)?',
|
||||
okLabel:'Delete them'}))) return;
|
||||
const o=document.getElementById('demo-out'); o.innerHTML='';
|
||||
// archived=all, or an archived DEMO-/SMOKE- project becomes unreachable from
|
||||
// this button — the default list hides it and nothing else here can delete it.
|
||||
@@ -250,10 +252,12 @@ async function archiveProject(id, name, archived){
|
||||
'• Nothing is deleted. Unarchive here at any time to bring it back.'
|
||||
: 'Unarchive “'+name+'”?\n\n'+
|
||||
'It becomes visible in the pickers again and can be edited as normal.';
|
||||
if(!confirm(ask)) return;
|
||||
if(!(await wpConfirmDialog({title:(archived?'Archive':'Unarchive')+' project',
|
||||
message:ask, okLabel:archived?'Archive':'Unarchive'}))) return;
|
||||
const { status, json } = await api('POST','/api/projects/'+id+'/archive',{archived:!!archived});
|
||||
if(status===200) loadProjects();
|
||||
else alert('Could not '+(archived?'archive':'unarchive')+' '+name+': '+((json && json.detail)||('HTTP '+status)));
|
||||
else wpAlertDialog({title:(archived?'Archive':'Unarchive')+' failed',
|
||||
message:'Could not '+(archived?'archive':'unarchive')+' '+name+': '+((json && json.detail)||('HTTP '+status))});
|
||||
}
|
||||
|
||||
// Named deleteProjectAdmin, not deleteProject: every function in this file is a
|
||||
@@ -261,13 +265,16 @@ async function archiveProject(id, name, archived){
|
||||
// enough to collide with one of them later. The -Admin suffix also says which of the
|
||||
// two project deletions this is — the console's, not a project member's.
|
||||
async function deleteProjectAdmin(id, name){
|
||||
if(!confirm('DELETE “'+name+'” permanently?\n\n'+
|
||||
'Its SOP, EVERY work package on it and every access assignment are deleted with it '+
|
||||
'(database cascade). This cannot be undone.\n\n'+
|
||||
'If you only want it out of the way, cancel and use Archive instead.')) return;
|
||||
if(!(await wpConfirmDialog({title:'Delete project permanently',
|
||||
message:'DELETE “'+name+'” permanently?\n\n'+
|
||||
'Its SOP, EVERY work package on it and every access assignment are deleted with it '+
|
||||
'(database cascade). This cannot be undone.\n\n'+
|
||||
'If you only want it out of the way, cancel and use Archive instead.',
|
||||
okLabel:'Delete permanently'}))) return;
|
||||
const { status, json } = await api('DELETE','/api/projects/'+id);
|
||||
if(status===200) loadProjects();
|
||||
else alert('Could not delete '+name+': '+((json && json.detail)||('HTTP '+status)));
|
||||
else wpAlertDialog({title:'Delete failed',
|
||||
message:'Could not delete '+name+': '+((json && json.detail)||('HTTP '+status))});
|
||||
}
|
||||
|
||||
// ── default members on new projects ─────────────────────────────────────────────
|
||||
@@ -373,7 +380,8 @@ async function setAutoAdd(id, username){
|
||||
_defMemUsers = _defMemUsers.map(u => u.id===json.id ? json : u);
|
||||
renderDefaultMembers();
|
||||
} else {
|
||||
alert('Could not change the new-project default for '+username+': '+((json && json.detail)||('HTTP '+status)));
|
||||
wpAlertDialog({title:'Change failed',
|
||||
message:'Could not change the new-project default for '+username+': '+((json && json.detail)||('HTTP '+status))});
|
||||
loadDefaultMembers();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
.ui-help-search{ flex:1; position:relative; max-width:420px; }
|
||||
.ui-help-search input{ width:100%; padding:8px 12px; border:1px solid var(--cds-border-strong); border-radius:0;
|
||||
font-size:13px; outline:none; background:var(--cds-layer-accent); }
|
||||
.ui-help-search input:focus{ border-color:var(--cds-focus); background:var(--cds-layer); box-shadow:0 0 0 2px rgba(37,99,214,.15); }
|
||||
.ui-help-search input:focus{ border-color:var(--cds-focus); background:var(--cds-layer); box-shadow:0 0 0 2px rgba(15,98,254,.15); }
|
||||
.ui-help-head .ui-help-x{ margin-left:auto; background:none; border:none; font-size:20px; cursor:pointer; color:var(--cds-text-secondary); line-height:1; }
|
||||
.ui-help-wrap{ display:flex; flex:1; min-height:0; }
|
||||
.ui-help-nav{ width:230px; flex:none; border-right:1px solid var(--cds-border-subtle); overflow:auto; padding:10px 8px; background:var(--cds-layer-accent); }
|
||||
|
||||
@@ -593,6 +593,7 @@
|
||||
<script src="feedback-config.js"></script>
|
||||
<script src="project-data.js"></script>
|
||||
<script src="help.js"></script>
|
||||
<script src="wp-dialog.js"></script>
|
||||
<script>
|
||||
// ── PROJECT SELECTION ─────────────────────────────────────────────────────
|
||||
const esc = ProjectData.esc;
|
||||
@@ -1046,7 +1047,7 @@
|
||||
const text = document.getElementById('comment-text').value.trim();
|
||||
|
||||
if (!text) {
|
||||
alert('Please enter feedback.');
|
||||
toast('Please enter feedback.', 'alert');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1067,7 +1068,7 @@
|
||||
function exportFeedback() {
|
||||
const saved = localStorage.getItem('wp_suite_index_comments');
|
||||
const data = saved ? JSON.parse(saved) : [];
|
||||
if (!data.length) { alert('No feedback to export yet.'); return; }
|
||||
if (!data.length) { toast('No feedback to export yet.', 'alert'); return; }
|
||||
const payload = { app: 'Work Package Suite', source: 'home', exportedAt: new Date().toISOString(), comments: data };
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||
const a = document.createElement('a');
|
||||
@@ -1085,7 +1086,7 @@
|
||||
try {
|
||||
const inc = JSON.parse(r.result);
|
||||
const incoming = Array.isArray(inc) ? inc : (inc.comments || []);
|
||||
if (!incoming.length) { alert('No feedback found in that file.'); return; }
|
||||
if (!incoming.length) { toast('No feedback found in that file.', 'alert'); return; }
|
||||
const saved = localStorage.getItem('wp_suite_index_comments');
|
||||
allComments = saved ? JSON.parse(saved) : [];
|
||||
const seen = new Set(allComments.map(c => c.timestamp + '|' + c.text));
|
||||
@@ -1093,8 +1094,8 @@
|
||||
incoming.forEach(c => { const k = c.timestamp + '|' + c.text; if (c.text && !seen.has(k)) { allComments.push(c); seen.add(k); added++; } });
|
||||
localStorage.setItem('wp_suite_index_comments', JSON.stringify(allComments));
|
||||
loadComments();
|
||||
alert('Imported ' + added + ' feedback item' + (added === 1 ? '' : 's') + '.');
|
||||
} catch (e) { alert('Could not read that file.'); }
|
||||
toast('Imported ' + added + ' feedback item' + (added === 1 ? '' : 's') + '.');
|
||||
} catch (e) { toast('Could not read that file.', 'alert'); }
|
||||
ev.target.value = '';
|
||||
};
|
||||
r.readAsText(f);
|
||||
|
||||
@@ -102,7 +102,8 @@
|
||||
</div>
|
||||
|
||||
<script src="console-util.js"></script>
|
||||
<script src="users.js"></script>
|
||||
<script src="wp-dialog.js"></script>
|
||||
<script src="users.js"></script>
|
||||
<!-- The app bar's project switcher reads ProjectData; without this the bar on this
|
||||
page could never show a project and always read "Select a project" (F1). Must
|
||||
parse before wp-chrome.js, which reads it as it mounts. -->
|
||||
|
||||
@@ -255,37 +255,44 @@ function projAccessCell(u){
|
||||
// Each one reloads on failure so a control can never sit there showing a value the
|
||||
// server refused.
|
||||
async function resetPw(id, username){
|
||||
const pw = prompt('New password for "'+username+'" (min 12 characters):');
|
||||
// The min-12 rule was stated in the prompt label and enforced only by the
|
||||
// server round-trip; the kit's validate() answers AT the input instead.
|
||||
const pw = await wpPromptDialog({title:'Reset password',
|
||||
message:'Set a new password for "'+username+'". Their existing sessions are signed out.',
|
||||
label:'New password (min 12 characters)',
|
||||
validate:v => (v && v.length >= 12) ? '' : 'At least 12 characters.'});
|
||||
if(pw === null) return;
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/password',{new_password:pw});
|
||||
if(status === 200) alert('Password reset for '+username+'. Their existing sessions are signed out.');
|
||||
else alert('Could not reset the password: '+apiError(status, json));
|
||||
if(status === 200) toast('Password reset for '+username+'. Their existing sessions are signed out.');
|
||||
else wpAlertDialog({title:'Reset failed', message:'Could not reset the password: '+apiError(status, json)});
|
||||
}
|
||||
|
||||
async function toggleActive(id, makeActive){
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/active',{is_active:makeActive});
|
||||
if(status === 200) loadUsers();
|
||||
else { alert('Could not change that account: '+apiError(status, json)); loadUsers(); }
|
||||
else { wpAlertDialog({title:'Change failed', message:'Could not change that account: '+apiError(status, json)}); loadUsers(); }
|
||||
}
|
||||
|
||||
async function changeRole(id, role, username){
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/role',{role});
|
||||
if(status !== 200) alert('Could not change permissions for '+username+': '+apiError(status, json));
|
||||
if(status !== 200) wpAlertDialog({title:'Change failed', message:'Could not change permissions for '+username+': '+apiError(status, json)});
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
async function changeProjectRole(id, project_role, username){
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/project-role',{project_role});
|
||||
if(status !== 200) alert('Could not set the project role for '+username+': '+apiError(status, json));
|
||||
if(status !== 200) wpAlertDialog({title:'Change failed', message:'Could not set the project role for '+username+': '+apiError(status, json)});
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
async function deleteUser(id, username){
|
||||
if(!confirm('Delete user "'+username+'"?\n\nTheir account and every project assignment go with it. '+
|
||||
'This cannot be undone — disable the account instead if you only want to block sign-in.')) return;
|
||||
if(!(await wpConfirmDialog({title:'Delete user',
|
||||
message:'Delete user "'+username+'"?\n\nTheir account and every project assignment go with it. '+
|
||||
'This cannot be undone — disable the account instead if you only want to block sign-in.',
|
||||
okLabel:'Delete user'}))) return;
|
||||
const { status, json } = await api('DELETE','/api/auth/users/'+id);
|
||||
if(status === 200) loadUsers();
|
||||
else alert('Could not delete '+username+': '+apiError(status, json));
|
||||
else wpAlertDialog({title:'Delete failed', message:'Could not delete '+username+': '+apiError(status, json)});
|
||||
}
|
||||
|
||||
// ── create ────────────────────────────────────────────────────────────────────
|
||||
@@ -368,7 +375,7 @@ async function createUser(){
|
||||
// more the person is on, and a save leaves those others untouched.
|
||||
async function manageProjects(id, username){
|
||||
const { status, json } = await api('GET','/api/auth/users/'+id+'/projects');
|
||||
if(status !== 200 || !json){ alert('Could not load projects: '+apiError(status, json)); return; }
|
||||
if(status !== 200 || !json){ wpAlertDialog({title:'Could not load projects', message:'Could not load projects: '+apiError(status, json)}); return; }
|
||||
openProjectModal(id, username, json);
|
||||
}
|
||||
function closeProjectModal(){ const m = document.getElementById('proj-modal'); if(m) m.remove(); }
|
||||
@@ -453,7 +460,7 @@ function openProjectModal(userId, username, data){
|
||||
const { status, json } = await api('PUT','/api/auth/users/'+userId+'/projects',
|
||||
{ project_ids: ids, roles: roleMap });
|
||||
if(status === 200){ closeProjectModal(); loadUsers(); }
|
||||
else alert('Save failed: '+apiError(status, json));
|
||||
else wpAlertDialog({title:'Save failed', message:'Save failed: '+apiError(status, json)});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3777,6 +3777,17 @@ function renderDashboard(){
|
||||
${card('Overdue', overdue, overdue?'dm-red':'', 'overdue')}
|
||||
${card('Est. hrs', Math.round(estH))}
|
||||
${card('Actual hrs', Math.round(actH))}
|
||||
${(()=>{
|
||||
// D12 (was BL-023): the productivity factor - actual against estimated,
|
||||
// the measure CR-017's tracking exists to enable. At or under 1.0 the
|
||||
// work beat the estimate (green); over it (red). Both hour fields are
|
||||
// optional, so with nothing to divide the card shows an em dash rather
|
||||
// than vanishing - a metric that disappears reads as "no such measure",
|
||||
// not "nothing logged yet". Server sums (B4), same as its neighbours.
|
||||
const pf = (estH > 0 && actH > 0) ? actH / estH : null;
|
||||
return card('Productivity (act/est)', pf === null ? '—' : pf.toFixed(2),
|
||||
pf === null ? '' : (pf <= 1 ? 'dm-green' : 'dm-red'));
|
||||
})()}
|
||||
</div>`;
|
||||
|
||||
// status + discipline breakdown chips (status chips also filter the board)
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
after .main is what lets it be a sticky column without wrapping the layout. */
|
||||
.wp-layout { display: flex; flex-direction: column; width: 100%; margin: 0; }
|
||||
.main { min-width: 0; max-width: none; margin: 0;
|
||||
padding: 22px 28px 72px calc(var(--nav-w,288px) + 28px);
|
||||
padding: 14px 28px 72px calc(var(--nav-w,288px) + 28px); /* F6: top pad only; bottom stays clear of the sticky bar */
|
||||
transition: padding-left .18s ease; }
|
||||
|
||||
.section { display: none; }
|
||||
@@ -308,7 +308,7 @@
|
||||
.deliv-text .dt-sub { display: block; font-size: 11px; color: var(--text-muted); margin-top: 1px; }
|
||||
|
||||
/* ── NAV ── */
|
||||
.nav-row { display: flex; justify-content: space-between; align-items: center; padding-top: 24px; margin-top: 24px; border-top: 1px solid var(--border); }
|
||||
.nav-row { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; margin-top: 14px; border-top: 1px solid var(--border); } /* F6 */
|
||||
.btn {
|
||||
padding: 10px 22px; border-radius: var(--radius); font-family: var(--mono); font-size: 11px; font-weight: 600;
|
||||
letter-spacing: .08em; cursor: pointer; border: 1px solid; transition: all .15s;
|
||||
@@ -537,7 +537,7 @@
|
||||
border-radius:var(--radius); padding:7px 10px; font-size:11px; }
|
||||
|
||||
/* ── CREATION TOOL ───────────────────────────────────────────────── */
|
||||
.ctx-bar { max-width:none; margin:0; padding:12px 28px 12px calc(var(--nav-w,288px) + 28px); display:flex; align-items:center; gap:20px;
|
||||
.ctx-bar { max-width:none; margin:0; padding:7px 28px 7px calc(var(--nav-w,288px) + 28px); display:flex; align-items:center; gap:20px; /* F6: denser, still the SOP identity strip */
|
||||
border-bottom:1px solid var(--border); background:var(--surface); flex-wrap:wrap; }
|
||||
.ctx-empty { color:var(--text-muted); font-size:13px; }
|
||||
.ctx-main .ctx-proj { font-weight:700; color:var(--text); font-size:14px; }
|
||||
@@ -575,7 +575,7 @@
|
||||
/* ── WORK PACKAGE FORM ───────────────────────────────────────────── */
|
||||
.sop-hint { color:var(--accent) !important; }
|
||||
.release-banner { max-width:none; margin:0; padding:0 28px 0 calc(var(--nav-w,288px) + 28px); }
|
||||
.release-banner .rb-inner { margin-top:14px; border-radius:var(--radius); padding:11px 16px; font-size:13px; font-weight:600;
|
||||
.release-banner .rb-inner { margin-top:8px; border-radius:var(--radius); padding:8px 16px; font-size:13px; font-weight:600; /* F6: A2's one warning, denser */
|
||||
display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
||||
.rb-ready { background:var(--accent-green-dim); color:var(--accent-green); border:1px solid var(--wp-status-success-border-b); }
|
||||
.rb-notready { background:var(--accent-amber-dim); color:var(--accent-amber); border:1px solid var(--wp-status-warning-border-b); }
|
||||
@@ -758,7 +758,13 @@
|
||||
/* A collapsed section should read as a ROW in a list, not as a box with one line
|
||||
in it. Eleven of them at the card's own 28px padding is 660px of nothing -
|
||||
which is a third of what F6 was measuring, arriving by a different door. */
|
||||
.card.collapsed { padding-top: 10px; padding-bottom: 10px; }
|
||||
/* F6 strict 2.0: a collapsed row is 36px on fine pointers - 13 of them at
|
||||
rest is where most of the two-screens overage lived. Coarse pointers keep
|
||||
the taller row below (the 44px tablet target, C1). */
|
||||
.card.collapsed { padding-top: 5px; padding-bottom: 5px; }
|
||||
@media (pointer: coarse) {
|
||||
.card.collapsed { padding-top: 10px; padding-bottom: 10px; }
|
||||
}
|
||||
.card.collapsed .section-header { margin-bottom: 0; padding-bottom: 0; border-bottom: 0; }
|
||||
.card.collapsed .sub-heading { margin-bottom: 0; }
|
||||
|
||||
|
||||
172
html/wp-dialog.js
Normal file
172
html/wp-dialog.js
Normal file
@@ -0,0 +1,172 @@
|
||||
/* Dialog kit + toast, shared (BL-024, 2026-08-20).
|
||||
*
|
||||
* The T7.9 kit, extracted for the pages the S1 tasks never named: the launcher
|
||||
* (index.html), the admin console and the user console carried 21 native
|
||||
* dialogs between them. Same contract as the creator's copy:
|
||||
*
|
||||
* wpConfirmDialog({title, message, okLabel, cancelLabel}) -> Promise<bool>
|
||||
* wpPromptDialog({title, message, label, value, validate}) -> Promise<string|null>
|
||||
* wpAlertDialog({title, message, okLabel}) -> Promise (value not meaningful)
|
||||
* toast(msg, kind) kind 'alert' interrupts (role=alert); default role=status
|
||||
*
|
||||
* Self-contained on purpose: markup and styles are injected on first use, the
|
||||
* styles are theme tokens only (the token rule), and the class names are its
|
||||
* own (wp-dlg-*) so the consoles' existing .modal styles are never touched.
|
||||
* The creator keeps its inline copy - it owns the same-id markup in its HTML -
|
||||
* so everything here is guarded: if the page already has the kit, this file
|
||||
* defines nothing.
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
if (typeof global.wpConfirmDialog === 'function') return; // the creator's copy wins
|
||||
|
||||
var CSS =
|
||||
'#wp-dlg-overlay{position:fixed;inset:0;background:var(--wp-scrim-cool-strong);' +
|
||||
'display:none;align-items:center;justify-content:center;z-index:10500;padding:20px;}' +
|
||||
'#wp-dlg-overlay.open{display:flex;}' +
|
||||
'.wp-dlg{background:var(--cds-layer);color:var(--cds-text-primary);max-width:480px;width:100%;' +
|
||||
'border-radius:8px;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;' +
|
||||
'font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;font-size:14px;}' +
|
||||
'.wp-dlg-head{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;' +
|
||||
'border-bottom:1px solid var(--cds-border-subtle);font-weight:700;}' +
|
||||
'.wp-dlg-x{background:none;border:none;font-size:18px;line-height:1;cursor:pointer;' +
|
||||
'color:var(--cds-text-secondary);padding:4px 6px;}' +
|
||||
'.wp-dlg-x:focus-visible{outline:2px solid var(--cds-focus);outline-offset:1px;}' +
|
||||
'.wp-dlg-body{padding:16px 18px;}' +
|
||||
'#wp-dlg-msg{white-space:pre-wrap;line-height:1.5;}' +
|
||||
'#wp-dlg-input-wrap{margin-top:10px;}' +
|
||||
'#wp-dlg-input-wrap label{display:block;font-size:12px;margin-bottom:4px;color:var(--cds-text-secondary);}' +
|
||||
'#wp-dlg-input{width:100%;box-sizing:border-box;padding:8px 10px;font:inherit;' +
|
||||
'border:1px solid var(--cds-border-strong);border-radius:4px;background:var(--cds-field);}' +
|
||||
'#wp-dlg-input:focus{outline:2px solid var(--cds-focus);outline-offset:-1px;}' +
|
||||
'#wp-dlg-err{color:var(--cds-text-error);font-size:12px;font-weight:600;margin-top:4px;}' +
|
||||
'#wp-dlg-err:empty{display:none;}' +
|
||||
'.wp-dlg-foot{display:flex;justify-content:flex-end;gap:10px;padding:12px 18px;' +
|
||||
'border-top:1px solid var(--cds-border-subtle);}' +
|
||||
'.wp-dlg-btn{font:inherit;font-weight:600;padding:8px 16px;border-radius:6px;cursor:pointer;' +
|
||||
'border:1px solid var(--cds-border-strong);background:var(--cds-layer);color:var(--cds-text-primary);}' +
|
||||
'.wp-dlg-btn.primary{background:var(--cds-interactive-01);border-color:var(--cds-interactive-01);' +
|
||||
'color:var(--cds-text-on-color);}' +
|
||||
'.wp-dlg-btn:focus-visible{outline:2px solid var(--cds-focus);outline-offset:1px;}' +
|
||||
'@media(pointer:coarse){.wp-dlg-btn{min-height:44px;}.wp-dlg-x{min-width:44px;min-height:44px;}}' +
|
||||
'#toast{position:fixed;bottom:26px;left:50%;transform:translateX(-50%) translateY(20px);' +
|
||||
'background:var(--cds-background-inverse);color:var(--cds-text-inverse);padding:9px 16px;' +
|
||||
'border-radius:6px;font-size:13px;opacity:0;transition:opacity .18s,transform .18s;' +
|
||||
'pointer-events:none;z-index:10600;max-width:min(480px,calc(100vw - 32px));}' +
|
||||
'#toast.show{opacity:1;transform:translateX(-50%) translateY(0);}';
|
||||
|
||||
function ensure() {
|
||||
var ov = document.getElementById('wp-dlg-overlay');
|
||||
if (ov) return ov;
|
||||
var st = document.createElement('style');
|
||||
st.textContent = CSS;
|
||||
document.head.appendChild(st);
|
||||
ov = document.createElement('div');
|
||||
ov.id = 'wp-dlg-overlay';
|
||||
ov.setAttribute('role', 'dialog');
|
||||
ov.setAttribute('aria-modal', 'true');
|
||||
ov.setAttribute('aria-labelledby', 'wp-dlg-title');
|
||||
ov.innerHTML =
|
||||
'<div class="wp-dlg">' +
|
||||
'<div class="wp-dlg-head"><div id="wp-dlg-title"></div>' +
|
||||
'<button type="button" class="wp-dlg-x" id="wp-dlg-x" title="Cancel" aria-label="Cancel">✕</button></div>' +
|
||||
'<div class="wp-dlg-body">' +
|
||||
'<div id="wp-dlg-msg"></div>' +
|
||||
'<div id="wp-dlg-input-wrap">' +
|
||||
'<label id="wp-dlg-label" for="wp-dlg-input"></label>' +
|
||||
'<input type="text" id="wp-dlg-input">' +
|
||||
'<div id="wp-dlg-err" role="alert"></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="wp-dlg-foot">' +
|
||||
'<button type="button" class="wp-dlg-btn" id="wp-dlg-cancel">Cancel</button>' +
|
||||
'<button type="button" class="wp-dlg-btn primary" id="wp-dlg-ok">OK</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
document.body.appendChild(ov);
|
||||
document.getElementById('wp-dlg-x').addEventListener('click', cancel);
|
||||
document.getElementById('wp-dlg-cancel').addEventListener('click', cancel);
|
||||
document.getElementById('wp-dlg-ok').addEventListener('click', ok);
|
||||
document.getElementById('wp-dlg-input').addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') ok();
|
||||
});
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && ov.classList.contains('open')) cancel();
|
||||
});
|
||||
return ov;
|
||||
}
|
||||
|
||||
var resolveFn = null;
|
||||
|
||||
function open(opts) {
|
||||
return new Promise(function (res) {
|
||||
resolveFn = res;
|
||||
var ov = ensure();
|
||||
ov._opts = opts || {};
|
||||
document.getElementById('wp-dlg-title').textContent = opts.title || 'Confirm';
|
||||
document.getElementById('wp-dlg-msg').textContent = opts.message || '';
|
||||
document.getElementById('wp-dlg-input-wrap').style.display = opts.input ? '' : 'none';
|
||||
document.getElementById('wp-dlg-label').textContent = opts.label || '';
|
||||
var inp = document.getElementById('wp-dlg-input');
|
||||
inp.value = (opts.value != null ? String(opts.value) : '');
|
||||
document.getElementById('wp-dlg-err').textContent = '';
|
||||
document.getElementById('wp-dlg-ok').textContent = opts.okLabel || 'OK';
|
||||
var cb = document.getElementById('wp-dlg-cancel');
|
||||
cb.textContent = opts.cancelLabel || 'Cancel';
|
||||
cb.style.display = opts.okOnly ? 'none' : '';
|
||||
ov.classList.add('open');
|
||||
setTimeout(function () {
|
||||
(opts.input ? inp : document.getElementById('wp-dlg-ok')).focus();
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
|
||||
function close(val) {
|
||||
var ov = document.getElementById('wp-dlg-overlay');
|
||||
if (ov) ov.classList.remove('open');
|
||||
var r = resolveFn;
|
||||
resolveFn = null;
|
||||
if (r) r(val);
|
||||
}
|
||||
|
||||
function ok() {
|
||||
var ov = document.getElementById('wp-dlg-overlay');
|
||||
var opts = (ov && ov._opts) || {};
|
||||
if (opts.input) {
|
||||
var v = document.getElementById('wp-dlg-input').value;
|
||||
if (opts.validate) {
|
||||
var err = opts.validate(v);
|
||||
if (err) {
|
||||
document.getElementById('wp-dlg-err').textContent = err;
|
||||
document.getElementById('wp-dlg-input').focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
close(v);
|
||||
} else close(true);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
var ov = document.getElementById('wp-dlg-overlay');
|
||||
var opts = (ov && ov._opts) || {};
|
||||
close(opts.input ? null : false);
|
||||
}
|
||||
|
||||
global.wpConfirmDialog = function (opts) { return open(Object.assign({}, opts, { input: false })); };
|
||||
global.wpPromptDialog = function (opts) { return open(Object.assign({}, opts, { input: true })); };
|
||||
global.wpAlertDialog = function (opts) { return open(Object.assign({}, opts, { input: false, okOnly: true })); };
|
||||
|
||||
if (typeof global.toast !== 'function') {
|
||||
// S10's rule, same as the creator: role BEFORE text, 'alert' interrupts.
|
||||
global.toast = function (msg, kind) {
|
||||
ensure();
|
||||
var t = document.getElementById('toast');
|
||||
if (!t) { t = document.createElement('div'); t.id = 'toast'; document.body.appendChild(t); }
|
||||
t.setAttribute('role', kind === 'alert' ? 'alert' : 'status');
|
||||
t.textContent = msg;
|
||||
t.classList.add('show');
|
||||
clearTimeout(global.toast._t);
|
||||
global.toast._t = setTimeout(function () { t.classList.remove('show'); }, 2200);
|
||||
};
|
||||
}
|
||||
})(window);
|
||||
@@ -105,6 +105,18 @@
|
||||
say(bits.join(''), problem);
|
||||
}
|
||||
|
||||
// A 500 answers plain text ("Internal Server Error"), and r.json() on that
|
||||
// throws - which used to land in catch() and read as "could not reach the
|
||||
// server" while the server was answering fine (found 2026-08-23, the
|
||||
// production locations import). Read text, parse if it parses, keep status.
|
||||
function readJson(r) {
|
||||
return r.text().then(function (t) {
|
||||
var j = null;
|
||||
try { j = t ? JSON.parse(t) : null; } catch (e) { /* not JSON: a proxy or 500 page */ }
|
||||
return { ok: r.ok, status: r.status, body: j };
|
||||
});
|
||||
}
|
||||
|
||||
function importText(dryRun) {
|
||||
var text = (el(p + '-paste') || {}).value || '';
|
||||
if (!text.trim()) { say('Paste some rows or choose a CSV file first.', true); return; }
|
||||
@@ -114,7 +126,7 @@
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ text: text, dry_run: !!dryRun }),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
|
||||
.then(readJson)
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
say('⚠ Import refused — ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
|
||||
@@ -142,7 +154,7 @@
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(read.payload),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
|
||||
.then(readJson)
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
setAddError((res.body && res.body.detail) || ('Could not add it (HTTP ' + res.status + ')'));
|
||||
@@ -161,7 +173,7 @@
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(patchBody),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
|
||||
.then(readJson)
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
say('⚠ ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
|
||||
|
||||
@@ -32,7 +32,11 @@ def upgrade() -> None:
|
||||
sa.Column('code', sa.String(length=80), nullable=False, server_default=''),
|
||||
sa.Column('description', sa.String(length=300), nullable=False, server_default=''),
|
||||
sa.Column('unit', sa.String(length=20), nullable=False, server_default=''),
|
||||
sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.text('1')),
|
||||
# sa.true(), not sa.text('1'): SQLite coerces integer 1 to boolean,
|
||||
# Postgres refuses it (DatatypeMismatch) - found when this migration
|
||||
# took down the wp.controls.dev api container on 2026-08-21. The
|
||||
# location-taxonomy migration next door had it right all along.
|
||||
sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column('sort', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
|
||||
110
server/app.py
110
server/app.py
@@ -458,15 +458,41 @@ def wp_link(db: Session, wp: "models.WorkPackage") -> str:
|
||||
return (base + path) if base else path
|
||||
|
||||
|
||||
def wp_titled(wp: "models.WorkPackage") -> str:
|
||||
"""Number — title, for a message body. Decided 2026-08-20: the title is
|
||||
customer CONTEXT and may ride in mail; document CONTENT may not."""
|
||||
t = (wp.subject or "").strip()
|
||||
n = wp.number or "a work package"
|
||||
return f"{n} — {t}" if t else n
|
||||
|
||||
|
||||
def wp_where(wp: "models.WorkPackage") -> str:
|
||||
"""Where the work happens, for a message body: the CR-004 structured
|
||||
fields (stored as paths — stable, and readable to the people these mails
|
||||
address), else the pre-CR-004 free text. Empty string when unset, and
|
||||
callers drop the line entirely rather than mail 'Where: '."""
|
||||
data = wp.data or {}
|
||||
parts = [str(data.get(d) or "").strip() for d in LOCATION_DIMENSIONS]
|
||||
parts = [p for p in parts if p]
|
||||
return " / ".join(parts) if parts else str(data.get("location") or "").strip()
|
||||
|
||||
|
||||
def _where_line(wp: "models.WorkPackage") -> str:
|
||||
w = wp_where(wp)
|
||||
return f"Where: {w}\n" if w else ""
|
||||
|
||||
|
||||
def assign_body(assignee: "models.User", wp: "models.WorkPackage", actor: "models.User", link: str) -> str:
|
||||
# Deliberately minimal — a WP number + a link, NOT the package contents (keeps
|
||||
# customer IP inside the app, behind login).
|
||||
# Number, title and location — customer context, allowed since the
|
||||
# 2026-08-20 decision (decisions-2026-08-20.md). Contents stay behind
|
||||
# the link: no scope text, no descriptions, no attachments.
|
||||
who = actor.full_name or actor.username
|
||||
name = assignee.full_name or assignee.username
|
||||
return (
|
||||
f"Hi {name},\n\n"
|
||||
f"{who} assigned you a work package: {wp.number or '(no number)'}.\n\n"
|
||||
f"Open the Work Package Suite to view and action it:\n{link}\n\n"
|
||||
f"{who} assigned you a work package: {wp_titled(wp)}.\n"
|
||||
+ _where_line(wp) +
|
||||
f"\nOpen the Work Package Suite to view and action it:\n{link}\n\n"
|
||||
f"— This is an automated message from the Work Package Suite."
|
||||
)
|
||||
|
||||
@@ -1597,15 +1623,22 @@ def project_sop_team(db: Session, project_id: Optional[str]) -> list[str]:
|
||||
).first()
|
||||
if not sop:
|
||||
return []
|
||||
proj = (sop.data or {}).get("project") or {}
|
||||
data = sop.data or {}
|
||||
# BL-021, fixed 2026-08-20: pushSOP writes the row as data={sop, state}, so
|
||||
# the project block lives at data['sop']['project']. This read was one
|
||||
# level too shallow - always {} - and the critical-reopen mail never
|
||||
# reached the PM or CM its docstring promises. Same tolerant read as
|
||||
# project_qa_group below: nested shape first, flat shape for hand-written rows.
|
||||
proj = ((data.get("sop") or {}).get("project")
|
||||
or data.get("project") or {})
|
||||
return [i for i in (proj.get("pmId"), proj.get("cmId")) if i]
|
||||
|
||||
|
||||
def project_qa_group(db: Session, project_id: Optional[str]) -> list["models.User"]:
|
||||
"""D2: the QA group named on the project's latest complete SOP. pushSOP writes
|
||||
the row as data={sop, state}, so the project block is data['sop']['project'] -
|
||||
note that project_sop_team above reads data['project'], which that shape never
|
||||
has (BL-021, logged, not fixed here)."""
|
||||
project_sop_team above read the flat shape until BL-021 was fixed
|
||||
(2026-08-20); both now read nested-first, exactly alike."""
|
||||
if not project_id:
|
||||
return []
|
||||
sop = db.scalars(
|
||||
@@ -1652,14 +1685,15 @@ def enforce_qa_rejection_comment(data: Optional[dict], new_status: str,
|
||||
|
||||
def qa_ready_body(user: "models.User", wp: "models.WorkPackage",
|
||||
actor: "models.User", link: str) -> str:
|
||||
# A WP number and a deep link - NOT the package contents. The task text asked
|
||||
# for location and a scope summary, but the done-when list (and the standing
|
||||
# rule) says no customer IP in a message body; the link is the summary.
|
||||
# Number, title and location ride in the body — the 2026-08-20 decision
|
||||
# restored the location the T7.6 done-when had excluded. The SCOPE summary
|
||||
# stays out: scope text is document content, and the link is its summary.
|
||||
who = actor.full_name or actor.username
|
||||
name = user.full_name or user.username
|
||||
return (
|
||||
f"Hi {name},\n\n"
|
||||
f"{who} moved {wp.number or 'a work package'} to Ready for QA.\n"
|
||||
f"{who} moved {wp_titled(wp)} to Ready for QA.\n"
|
||||
+ _where_line(wp) +
|
||||
f"It is in the QA queue waiting to be accepted or returned.\n\n"
|
||||
f"Open it here:\n{link}\n\n"
|
||||
f"— This is an automated message from the Work Package Suite."
|
||||
@@ -1672,7 +1706,8 @@ def qa_reject_body(user: "models.User", wp: "models.WorkPackage",
|
||||
name = user.full_name or user.username
|
||||
return (
|
||||
f"Hi {name},\n\n"
|
||||
f"{who} returned {wp.number or 'a work package'} from Ready for QA to In Progress.\n"
|
||||
f"{who} returned {wp_titled(wp)} from Ready for QA to In Progress.\n"
|
||||
+ _where_line(wp) +
|
||||
f"The reason is recorded on the package.\n\n"
|
||||
f"Open it here:\n{link}\n\n"
|
||||
f"— This is an automated message from the Work Package Suite."
|
||||
@@ -1705,18 +1740,20 @@ def notify_qa_transition(db: Session, wp: "models.WorkPackage",
|
||||
|
||||
def hold_body(user: "models.User", wp: "models.WorkPackage", names: list[str],
|
||||
actor: "models.User", link: str) -> str:
|
||||
# Constraint names and a WP number only — no package contents, same rule as the
|
||||
# assignment mail.
|
||||
# Constraint names, number, title and location — customer context per the
|
||||
# 2026-08-20 decision. No package contents; the link carries those.
|
||||
who = user.full_name or user.username
|
||||
by = actor.full_name or actor.username
|
||||
which = ", ".join(names)
|
||||
return (
|
||||
f"Hi {who},\n\n"
|
||||
f"A critical constraint was reopened on {wp.number or 'a work package'} "
|
||||
f"A critical constraint was reopened on {wp_titled(wp)} "
|
||||
f"after it was released to the field, so the package is on hold.\n\n"
|
||||
f"Constraint: {which}\n"
|
||||
+ _where_line(wp) +
|
||||
f"Reopened by: {by}\n\n"
|
||||
f"Open the package:\n{link}\n"
|
||||
f"Open the package:\n{link}\n\n"
|
||||
f"— This is an automated message from the Work Package Suite."
|
||||
)
|
||||
|
||||
|
||||
@@ -1733,7 +1770,7 @@ def kitting_body(user: "models.User", wp: "models.WorkPackage", actor: "models.U
|
||||
or (wp.data or {}).get("mimoLoc") or "").strip() or "not set"
|
||||
return (
|
||||
f"Hi {name},\n\n"
|
||||
f"{who} moved kitting on {wp.number or 'a work package'} from "
|
||||
f"{who} moved kitting on {wp_titled(wp)} from "
|
||||
f"{old_status or 'Not Started'} to {new_status or 'Not Started'}.\n"
|
||||
f"Delivery location: {delivery}.\n\n"
|
||||
f"Open it here:\n{link}\n\n"
|
||||
@@ -1749,7 +1786,7 @@ def material_request_body(user: "models.User", wp: "models.WorkPackage",
|
||||
needed_line = f" needed by {needed}" if needed else ""
|
||||
return (
|
||||
f"Hi {name},\n\n"
|
||||
f"{who} raised a material request on {wp.number or 'a work package'}: "
|
||||
f"{who} raised a material request on {wp_titled(wp)}: "
|
||||
f"{n_lines} line{'' if n_lines == 1 else 's'}{needed_line}.\n"
|
||||
f"Delivery location: {delivery}.\n\n"
|
||||
f"Open it here:\n{link}\n\n"
|
||||
@@ -2351,6 +2388,24 @@ def parse_location_rows(text: str) -> tuple[list[tuple[int, list[str]]], list[di
|
||||
rejected.append({"line": i, "text": line,
|
||||
"reason": "no letters or digits to make a code from"})
|
||||
continue
|
||||
# Postgres enforces VARCHAR lengths and refuses NUL/control bytes;
|
||||
# SQLite shrugs at both - which is how ONE bad CSV line 500'd the whole
|
||||
# production import (2026-08-23, BL-027's class again) instead of coming
|
||||
# back as a rejection with its line number. Validate per row, here,
|
||||
# so every dialect answers the same way: with a reason.
|
||||
if any(any(ord(ch) < 32 for ch in p) for p in parts):
|
||||
rejected.append({"line": i, "text": line[:120],
|
||||
"reason": "contains control characters — re-save the file as plain CSV (UTF-8)"})
|
||||
continue
|
||||
long_p = next((p for p in parts if len(p) > 200), None)
|
||||
if long_p is not None:
|
||||
rejected.append({"line": i, "text": line[:120],
|
||||
"reason": "a name is longer than 200 characters (%d)" % len(long_p)})
|
||||
continue
|
||||
if any(len(location_slug(p)) > 60 for p in parts):
|
||||
rejected.append({"line": i, "text": line[:120],
|
||||
"reason": "a code would be longer than 60 characters"})
|
||||
continue
|
||||
rows.append((i, parts))
|
||||
return rows, rejected
|
||||
|
||||
@@ -2417,6 +2472,7 @@ def import_locations(project_id: str, body: LocationImportIn,
|
||||
require_project_writable(db, user, project_id, "The location list cannot be changed")
|
||||
|
||||
rows, rejected = parse_location_rows(body.text)
|
||||
read_total = len(rows) + len(rejected)
|
||||
|
||||
existing = {n.path: n for n in db.scalars(
|
||||
select(models.LocationNode).where(models.LocationNode.project_id == project_id)
|
||||
@@ -2431,6 +2487,10 @@ def import_locations(project_id: str, body: LocationImportIn,
|
||||
for line_no, parts in rows:
|
||||
segs = [location_slug(p) for p in parts]
|
||||
full = "/".join(segs)
|
||||
if len(full) > 200:
|
||||
rejected.append({"line": line_no, "text": "/".join(parts)[:120],
|
||||
"reason": "the combined path is longer than 200 characters"})
|
||||
continue
|
||||
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]})
|
||||
@@ -2473,7 +2533,7 @@ def import_locations(project_id: str, body: LocationImportIn,
|
||||
|
||||
result = {
|
||||
"project_id": project_id, "dry_run": bool(body.dry_run),
|
||||
"read": len(rows) + len(rejected),
|
||||
"read": read_total,
|
||||
"created": created, "duplicates": duplicates,
|
||||
"reactivated": reactivated, "rejected": rejected,
|
||||
}
|
||||
@@ -2970,6 +3030,18 @@ def parse_material_rows(text: str):
|
||||
rejected.append({"line": i, "text": raw.strip()[:120],
|
||||
"reason": "more than three columns - description, unit, code is the whole shape"})
|
||||
continue
|
||||
# Same guard as parse_location_rows: reject what Postgres would refuse
|
||||
# (VARCHAR limits, control bytes) with the line number, never a 500.
|
||||
if any(any(ord(ch) < 32 for ch in p) for p in parts):
|
||||
rejected.append({"line": i, "text": raw.strip()[:120],
|
||||
"reason": "contains control characters - re-save the file as plain CSV (UTF-8)"})
|
||||
continue
|
||||
caps = ((300, "description"), (20, "unit"), (80, "code"))
|
||||
long_col = next((("%s is longer than %d characters (%d)" % (label, cap, len(p)))
|
||||
for (cap, label), p in zip(caps, parts) if len(p) > cap), None)
|
||||
if long_col:
|
||||
rejected.append({"line": i, "text": raw.strip()[:120], "reason": long_col})
|
||||
continue
|
||||
rows.append((i, parts))
|
||||
return rows, rejected
|
||||
|
||||
|
||||
@@ -7,11 +7,14 @@ and is NEVER stored in the database or shown in the UI.
|
||||
|
||||
Every notable event (e.g. a WP assignment) writes a `notifications` row — an in-app
|
||||
record — and, when email is on + SMTP is set, the row is delivered by email in a
|
||||
background task. Notification bodies deliberately avoid customer IP: they carry a WP
|
||||
number and a deep link, not the work-package contents.
|
||||
background task. Notification bodies carry customer CONTEXT — the WP number, its
|
||||
title, where the work happens — and a deep link, never customer document CONTENT
|
||||
(scope text, descriptions, comments, attachments). Decided 2026-08-20; the link is
|
||||
the summary of everything a body leaves out.
|
||||
"""
|
||||
import os
|
||||
import smtplib
|
||||
import socket
|
||||
import uuid
|
||||
import logging
|
||||
from email.message import EmailMessage
|
||||
@@ -107,7 +110,13 @@ def send_email(s: dict, to_addr: str, subject: str, body: str) -> None:
|
||||
port = int(s.get("smtp_port") or 587)
|
||||
user = s.get("smtp_username") or ""
|
||||
pw = os.getenv("SMTP_PASSWORD", "")
|
||||
with smtplib.SMTP(host, port, timeout=15) as srv:
|
||||
# local_hostname pins the EHLO name. Without it smtplib calls getfqdn() on
|
||||
# EVERY connect, and that reverse-DNS lookup stalls ~5s per send whenever DNS
|
||||
# is slow or unreachable - sends are sequential background tasks, so a batch
|
||||
# of notifications trickled out one per five seconds. gethostname() never
|
||||
# touches the network. Found 2026-08-20 when the office link dropped.
|
||||
with smtplib.SMTP(host, port, timeout=15,
|
||||
local_hostname=(socket.gethostname() or "wp-suite")) as srv:
|
||||
if s.get("smtp_use_tls", True):
|
||||
srv.starttls()
|
||||
if user:
|
||||
|
||||
@@ -155,6 +155,18 @@ def main():
|
||||
str(shown) == str(as_root["total"]),
|
||||
"shown=%r server=%r (poisoned cache said 2)" % (shown, as_root["total"]))
|
||||
chk("...and is therefore not the poisoned cache's 2", str(shown) != "2", shown)
|
||||
# D12: the productivity factor card, computed from the SAME server
|
||||
# sums as its neighbours. Both hour fields are optional (CR-017),
|
||||
# so the expected value is derived, not hardcoded: a real quotient
|
||||
# when both sums exist, an em dash when either is zero.
|
||||
pf_shown = page.eval(
|
||||
"(()=>{const e=[...document.querySelectorAll('.dash-metric')]"
|
||||
".find(x=>/Productivity/i.test(x.textContent));"
|
||||
"return e?e.querySelector('.dm-val').textContent.trim():null})()")
|
||||
est, act = as_root.get("est_hours") or 0, as_root.get("actual_hours") or 0
|
||||
pf_want = ("%.2f" % (act / est)) if est > 0 and act > 0 else "—"
|
||||
chk("the D12 productivity card shows actual/estimated from the server sums",
|
||||
pf_shown == pf_want, "shown=%r want=%r (est=%r act=%r)" % (pf_shown, pf_want, est, act))
|
||||
|
||||
print("\n3. a failed aggregate request is an error, not a zero")
|
||||
page.eval("""(() => {
|
||||
|
||||
@@ -53,8 +53,11 @@ def main():
|
||||
print("\n2. one blue, one amber")
|
||||
theme = io.open(os.path.join(HTML, "theme-light.css"), encoding="utf-8").read()
|
||||
code = strip_comments(theme, True)
|
||||
# BL-025 widened this: the rgb spelling is compared space-free, because
|
||||
# rgba(37,99,214,.15) in help.js slid past the spaced grep for months.
|
||||
chk("the second brand blue (#2563d6) is gone from the theme itself",
|
||||
"2563d6" not in code and "37, 99, 214" not in code)
|
||||
"2563d6" not in code.lower()
|
||||
and "37,99,214" not in code.replace(" ", ""))
|
||||
chk("the ninth amber (--wp-status-warning-text-alt) is deleted",
|
||||
"--wp-status-warning-text-alt" not in code)
|
||||
others = []
|
||||
@@ -63,7 +66,8 @@ def main():
|
||||
continue
|
||||
src = strip_comments(io.open(os.path.join(HTML, name), encoding="utf-8").read(),
|
||||
name.endswith(".css"))
|
||||
if "warning-text-alt" in src or "2563d6" in src:
|
||||
if ("warning-text-alt" in src or "2563d6" in src.lower()
|
||||
or "37,99,214" in src.replace(" ", "")):
|
||||
others.append(name)
|
||||
chk("...and no consumer still references either", not others, others)
|
||||
|
||||
|
||||
149
tests/console_dialogs_check.py
Normal file
149
tests/console_dialogs_check.py
Normal file
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Are the consoles' and launcher's 21 native dialogs gone? — BL-024, 2026-08-20.
|
||||
|
||||
S1 counted 79 native dialogs app-wide; its two tasks (T5.8 wizard, T7.9
|
||||
creator) removed 58 and the audit found the remaining 21 on surfaces no S1
|
||||
task named: admin.js (6), users.js (10), the launcher's inline script (5).
|
||||
They now go through `wp-dialog.js` — the T7.9 kit extracted as a shared,
|
||||
self-injecting component (guarded so the creator's inline copy still wins on
|
||||
its own page).
|
||||
|
||||
Static half greps the counts; browser half drives the password-reset prompt on
|
||||
the users console with natives poisoned, and proves validate() answers AT the
|
||||
input while the server round-trip completes end to end.
|
||||
|
||||
Boots its own throwaway SQLite + uvicorn + headless browser; run it alone.
|
||||
Exit 0 all passed, 1 a failure, 2 could not run.
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
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 # noqa: E402
|
||||
from qa_gate_check import api # noqa: E402
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
HTML = os.path.join(ROOT, "html")
|
||||
|
||||
|
||||
def ascii_(v, n=240):
|
||||
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
|
||||
|
||||
|
||||
def strip_js(src):
|
||||
src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
|
||||
return "\n".join(re.sub(r"(?<![:'\"])//.*$", "", ln) for ln in src.split("\n"))
|
||||
|
||||
|
||||
def natives(name):
|
||||
code = strip_js(io.open(os.path.join(HTML, name), encoding="utf-8").read())
|
||||
return len(re.findall(r"(?<![\w.$])(alert|confirm|prompt)\(", code))
|
||||
|
||||
|
||||
def main():
|
||||
exe = cdp.find_browser()
|
||||
if not exe:
|
||||
print("no headless-capable browser found; set WP_BROWSER.")
|
||||
return 2
|
||||
|
||||
print("\n1. the counts (baseline 6 + 10 + 5 = 21)")
|
||||
for name in ("admin.js", "users.js", "index.html"):
|
||||
chk("%s: 0 native dialogs" % name, natives(name) == 0, natives(name))
|
||||
chk("wp-dialog.js exists, has the guard, and no natives of its own",
|
||||
natives("wp-dialog.js") == 0
|
||||
and "typeof global.wpConfirmDialog === 'function'" in
|
||||
io.open(os.path.join(HTML, "wp-dialog.js"), encoding="utf-8").read())
|
||||
for page in ("index.html", "admin.html", "users.html"):
|
||||
chk("%s loads the kit" % page,
|
||||
'src="wp-dialog.js"' in io.open(os.path.join(HTML, page), encoding="utf-8").read())
|
||||
chk("the creator keeps its own copy (it owns the same-id markup in its HTML)",
|
||||
"function wpConfirmDialog" in
|
||||
io.open(os.path.join(HTML, "wp-creation-app.js"), encoding="utf-8").read())
|
||||
|
||||
tmpdir = tempfile.mkdtemp(prefix="wpsuite-condlg-")
|
||||
db_path = os.path.join(tmpdir, "check.db")
|
||||
server = None
|
||||
browser = None
|
||||
try:
|
||||
tok = seed(db_path)
|
||||
port = cdp.free_port()
|
||||
base = "http://127.0.0.1:%d" % port
|
||||
server = start_server(port, db_path)
|
||||
|
||||
print("\n2. the users console, natives poisoned")
|
||||
browser = cdp.Browser(exe)
|
||||
page = browser.page()
|
||||
page.clear_cookies()
|
||||
page.set_cookie("wp_session", tok["root"])
|
||||
page.viewport(1440, 900)
|
||||
page.goto(base + "/users.html")
|
||||
time.sleep(2.0)
|
||||
page.eval("window.alert=()=>{throw new Error('native alert reached')};"
|
||||
"window.confirm=()=>{throw new Error('native confirm reached')};"
|
||||
"window.prompt=()=>{throw new Error('native prompt reached')};")
|
||||
chk("the console booted with a user table",
|
||||
page.eval("!!document.querySelector('table')"))
|
||||
|
||||
page.eval("void resetPw('user_pat','pat')")
|
||||
time.sleep(0.4)
|
||||
chk("the reset prompt is the kit's modal, open, focused at the input",
|
||||
page.eval("(() => { const o=document.getElementById('wp-dlg-overlay');"
|
||||
" return !!o && o.classList.contains('open')"
|
||||
" && document.activeElement.id==='wp-dlg-input'; })()"))
|
||||
page.eval("document.getElementById('wp-dlg-input').value='short';"
|
||||
"document.getElementById('wp-dlg-ok').click()")
|
||||
chk("a short password is refused AT the input - dialog stays, error says why",
|
||||
page.eval("(() => { const o=document.getElementById('wp-dlg-overlay');"
|
||||
" return o.classList.contains('open')"
|
||||
" && /12 characters/.test(document.getElementById('wp-dlg-err').textContent); })()"))
|
||||
page.eval("document.getElementById('wp-dlg-input').value='CorrectHorseBattery10';"
|
||||
"document.getElementById('wp-dlg-ok').click()")
|
||||
time.sleep(1.2)
|
||||
chk("a good answer closes the dialog and the server accepts it",
|
||||
page.eval("!document.getElementById('wp-dlg-overlay').classList.contains('open')"))
|
||||
chk("...announced through the kit's toast (role=status)",
|
||||
page.eval("(() => { const t=document.getElementById('toast');"
|
||||
" return !!t && t.getAttribute('role')==='status'"
|
||||
" && /Password reset for pat/.test(t.textContent); })()"))
|
||||
st, _ = api(base, "/api/auth/login", "x", "POST",
|
||||
{"username": "pat", "password": "CorrectHorseBattery10"})
|
||||
chk("...and the new password actually works", st == 200, st)
|
||||
|
||||
print("\n3. destroy needs a real yes")
|
||||
page.eval("void deleteUser('user_bob','bob')")
|
||||
time.sleep(0.4)
|
||||
chk("the delete asks through the kit, spelling out what goes with it",
|
||||
page.eval("(() => { const o=document.getElementById('wp-dlg-overlay');"
|
||||
" return o.classList.contains('open')"
|
||||
" && /cannot be undone/.test(document.getElementById('wp-dlg-msg').textContent); })()"))
|
||||
page.eval("document.getElementById('wp-dlg-cancel').click()")
|
||||
time.sleep(0.6)
|
||||
st, users = api(base, "/api/auth/users", tok["root"])
|
||||
chk("cancel means no: bob is still an account",
|
||||
st == 200 and any(u.get("username") == "bob" for u in (users or [])),
|
||||
ascii_([u.get("username") for u in (users or [])]))
|
||||
errs = [e for e in page.js_errors() if "beforeunload" not in e]
|
||||
chk("no JavaScript errors, and no path reached a native dialog (they throw here)",
|
||||
not errs, ascii_(errs[:3]))
|
||||
finally:
|
||||
if browser:
|
||||
browser.close()
|
||||
if server:
|
||||
server.terminate()
|
||||
|
||||
print("\n" + "-" * 54)
|
||||
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
|
||||
for f in _FAIL:
|
||||
print(" - " + f)
|
||||
return 1 if _FAIL else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
135
tests/critical_reopen_check.py
Normal file
135
tests/critical_reopen_check.py
Normal file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Does the critical-reopen mail reach the PM and CM? — BL-021, fixed 2026-08-20.
|
||||
|
||||
`project_sop_team()` read `sop.data['project']`, but `pushSOP` stores every row
|
||||
as `data={sop, state}` — the project block is one level deeper. The lookup
|
||||
returned `[]` for every real row, so the on-hold email's recipient list was
|
||||
silently reduced to assignee + distribution: the PM and CM named in
|
||||
`notify_critical_reopen`'s own docstring never got it, from the day it shipped.
|
||||
|
||||
The fixture writes the PRODUCTION shape (nested under 'sop'), because a
|
||||
hand-built flat row would have passed against the bug — which is exactly how it
|
||||
went unverified this long. Sink pattern from qa_gate_check, one implementation.
|
||||
|
||||
Boots its own throwaway SQLite + uvicorn; run it alone, not back to back.
|
||||
Exit 0 all passed, 1 a failure, 2 could not run.
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
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 # noqa: E402
|
||||
from sections_check import set_sop # noqa: E402
|
||||
from qa_gate_check import SmtpSink, api, wait_for # noqa: E402
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def ascii_(v, n=280):
|
||||
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
|
||||
|
||||
|
||||
def set_team(pm_id, cm_id):
|
||||
"""The PRODUCTION shape: data['sop']['project'], as pushSOP writes it."""
|
||||
from server.db import SessionLocal
|
||||
from server import models
|
||||
with SessionLocal() as db:
|
||||
sop = db.get(models.Sop, "sopA")
|
||||
data = json.loads(json.dumps(sop.data or {}))
|
||||
proj = data.setdefault("sop", {}).setdefault("project", {})
|
||||
proj["pmId"], proj["cmId"] = pm_id, cm_id
|
||||
sop.data = data
|
||||
db.commit()
|
||||
|
||||
|
||||
def main():
|
||||
print("\n1. the read matches the written shape (static)")
|
||||
src = io.open(os.path.join(ROOT, "server", "app.py"), encoding="utf-8").read()
|
||||
fn = src[src.index("def project_sop_team"):src.index("def project_qa_group")]
|
||||
chk("project_sop_team reads the nested data['sop']['project'] first",
|
||||
'.get("sop")' in fn and '.get("project")' in fn)
|
||||
|
||||
tmpdir = tempfile.mkdtemp(prefix="wpsuite-reopen-")
|
||||
db_path = os.path.join(tmpdir, "check.db")
|
||||
server = None
|
||||
sink = SmtpSink()
|
||||
sink.start()
|
||||
try:
|
||||
tok = seed(db_path)
|
||||
set_sop(db_path, {})
|
||||
set_team("user_sue", "user_pat")
|
||||
port = cdp.free_port()
|
||||
base = "http://127.0.0.1:%d" % port
|
||||
server = start_server(port, db_path)
|
||||
root = tok["root"]
|
||||
|
||||
api(base, "/api/settings", root, "PUT", {
|
||||
"email_enabled": True, "smtp_host": "127.0.0.1", "smtp_port": sink.port,
|
||||
"smtp_use_tls": False, "from_addr": "suite@sink.local",
|
||||
"app_base_url": base})
|
||||
|
||||
print("\n2. a released package, its critical constraint reopened")
|
||||
body = {
|
||||
"id": "wpCR1", "project_id": "projA", "number": "CR-01",
|
||||
"subject": "energize MCC-4", "status": "In Progress",
|
||||
"assignee_id": "user_mix",
|
||||
"data": {"location": "B-100 / Level 2",
|
||||
"constraints": [{"name": "Power shutdown", "status": "cleared",
|
||||
"critical": True, "comment": ""}]}}
|
||||
code, _ = api(base, "/api/wps", root, "POST", body)
|
||||
chk("the package saves", code == 200, code)
|
||||
# The creation enqueues a wp_assigned mail delivered by a background
|
||||
# task; wait for it BEFORE clearing or it leaks into the reopen count.
|
||||
wait_for(lambda: len(sink.messages) >= 1, 10)
|
||||
code, _ = api(base, "/api/wps/wpCR1/status", root, "POST", {"status": "Issued"})
|
||||
chk("...and releases (the critical constraint is cleared)", code == 200, code)
|
||||
sink.messages.clear()
|
||||
|
||||
body["status"] = "Issued"
|
||||
body["data"]["constraints"][0]["status"] = "open"
|
||||
code, wp = api(base, "/api/wps", root, "POST", body)
|
||||
chk("reopening the critical constraint saves through the normal upsert",
|
||||
code == 200, code)
|
||||
|
||||
print("\n3. the mail reaches everyone the docstring promises")
|
||||
chk("three messages: assignee + PM + CM (the actor is excluded)",
|
||||
wait_for(lambda: len(sink.messages) == 3, 15), len(sink.messages))
|
||||
rcpts = sorted(m["to"][0] for m in sink.messages)
|
||||
chk("...the PM and CM are among them - THE BL-021 fix, sink-verified",
|
||||
"sue@example.test" in rcpts and "pat@example.test" in rcpts, ascii_(rcpts))
|
||||
chk("...and the assignee, exactly them, nobody twice",
|
||||
rcpts == ["mix@example.test", "pat@example.test", "sue@example.test"],
|
||||
ascii_(rcpts))
|
||||
text = next((m["text"] for m in sink.messages if "On hold:" in m["data"]), "")
|
||||
chk("the body names the constraint and the package, title included (CR-014)",
|
||||
"Power shutdown" in text and "CR-01" in text and "energize MCC-4" in text,
|
||||
ascii_(text, 300))
|
||||
chk("...and where the work happens, and a deep link to THAT package",
|
||||
"B-100 / Level 2" in text
|
||||
and "/wp-creation-index.html?project=projA&wp=wpCR1" in text, ascii_(text, 300))
|
||||
chk("...in the house convention (the footer this body alone used to lack)",
|
||||
"automated message from the Work Package Suite" in text)
|
||||
_, ev = api(base, "/api/audit?entity_type=wp&entity_id=wpCR1"
|
||||
"&action=constraint_reopened", root)
|
||||
chk("the reopen is in the audit history", bool(ev), ascii_(ev[:1] if ev else ev))
|
||||
finally:
|
||||
sink.stop()
|
||||
if server:
|
||||
server.terminate()
|
||||
|
||||
print("\n" + "-" * 54)
|
||||
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
|
||||
for f in _FAIL:
|
||||
print(" - " + f)
|
||||
return 1 if _FAIL else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -106,7 +106,7 @@ def main():
|
||||
rcpts = sorted(m["to"][0] for m in sink.messages)
|
||||
chk("...exactly them, actor excluded",
|
||||
rcpts == ["pat@example.test", "sue@example.test"], ascii_(rcpts))
|
||||
body = sink.messages[0]["data"] if sink.messages else ""
|
||||
body = sink.messages[0]["text"] if sink.messages else ""
|
||||
chk("the mail says old status, new status and who",
|
||||
"In Transit" in body and "Delivered" in body and "Root" in body,
|
||||
ascii_(body, 260))
|
||||
|
||||
@@ -323,6 +323,13 @@ def run(page, base, tok):
|
||||
{"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"])
|
||||
# The 2026-08-23 production 500, pinned (locations side): Postgres-refused
|
||||
# values reject by line, on every dialect, never crash the request.
|
||||
hz = api(page, "POST", "/api/projects/projA/locations/import",
|
||||
{"text": "Probe Building One," + "Y" * 220 + ",S1", "dry_run": True})
|
||||
chk("an over-long name is a line rejection, not a 500",
|
||||
hz["status"] == 200 and hz["body"]["rejected"]
|
||||
and "200 characters" in hz["body"]["rejected"][0]["reason"], hz["body"])
|
||||
same = [n for n in api(page, "GET",
|
||||
"/api/projects/projA/locations?include_inactive=true")["body"]["nodes"]
|
||||
if n["path"] == sec["path"]]
|
||||
|
||||
@@ -54,6 +54,20 @@ def main():
|
||||
chk("locations and materials are both instances of it - not a copy beside it",
|
||||
"locList = WPListImport(" in suite and "matList = WPListImport(" in suite
|
||||
and "function locImport(dryRun){ locList.importText" in suite)
|
||||
# The 2026-08-21 outage, pinned: a Boolean server_default of sa.text('1')
|
||||
# passes on SQLite (which coerces 1) and crash-loops Postgres at deploy
|
||||
# (DatatypeMismatch). Every migration must say sa.true()/sa.false().
|
||||
import re as _re
|
||||
bad = []
|
||||
vdir = os.path.join(ROOT, "server", "alembic", "versions")
|
||||
for fn in sorted(os.listdir(vdir)):
|
||||
if not fn.endswith(".py"):
|
||||
continue
|
||||
for ln in open(os.path.join(vdir, fn), encoding="utf-8"):
|
||||
if "Boolean" in ln and "server_default" in ln and not _re.search(r"server_default=sa\.(true|false)\(\)", ln):
|
||||
bad.append("%s: %s" % (fn, ln.strip()[:90]))
|
||||
chk("no migration gives a Boolean a non-portable server_default "
|
||||
"(sa.true()/sa.false() only)", not bad, ascii_(bad[:3]))
|
||||
model = open(os.path.join(ROOT, "server", "models.py"), encoding="utf-8").read()
|
||||
mat_block = model[model.find("class MaterialItem"):model.find("class WpFile")]
|
||||
cols = re.findall(r"^\s+(\w+): Mapped", mat_block, re.M)
|
||||
@@ -91,6 +105,20 @@ def main():
|
||||
_, listing = api(base, "/api/projects/projA/materials", root)
|
||||
chk("...and nothing was written", listing["items"] == [])
|
||||
|
||||
# The 2026-08-23 production 500, pinned: what Postgres refuses (VARCHAR
|
||||
# overflow, control bytes) must come back as a per-line rejection - on
|
||||
# EVERY dialect - never crash the request.
|
||||
code, rep = api(base, "/api/projects/projA/materials/import", root, "POST",
|
||||
{"text": "Sample " + "x" * 300 + ",EA", "dry_run": True})
|
||||
chk("an over-long description is a line rejection, not a 500",
|
||||
code == 200 and rep["rejected"] and "300 characters" in rep["rejected"][0]["reason"]
|
||||
and not rep["created"], ascii_(rep))
|
||||
code, rep = api(base, "/api/projects/projA/materials/import", root, "POST",
|
||||
{"text": "Sample widget\u0000,EA", "dry_run": True})
|
||||
chk("a control byte is a line rejection, not a 500",
|
||||
code == 200 and rep["rejected"]
|
||||
and "control characters" in rep["rejected"][0]["reason"], ascii_(rep))
|
||||
|
||||
code, rep = api(base, "/api/projects/projA/materials/import", root, "POST",
|
||||
{"text": text, "dry_run": False})
|
||||
_, listing = api(base, "/api/projects/projA/materials", root)
|
||||
|
||||
@@ -121,13 +121,14 @@ def main():
|
||||
len((wp.get("data") or {}).get("materialRequests") or []) == 1)
|
||||
chk("...and the warehouse owner is notified through the T7.6 gate",
|
||||
wait_for(lambda: any("Material request" in m["data"] for m in sink.messages), 12))
|
||||
body = next((m for m in sink.messages if "Material request" in m["data"]), {"data": "", "to": [""]})
|
||||
body = next((m for m in sink.messages if "Material request" in m["data"]),
|
||||
{"data": "", "text": "", "to": [""]})
|
||||
chk("...the mail goes to the owner, says the size, the date, the delivery "
|
||||
"and carries the deep link",
|
||||
body["to"] == ["sue@example.test"] and "2 lines" in body["data"]
|
||||
and "2026-09-01" in body["data"] and "Shark cage 7" in body["data"]
|
||||
and ("/wp-creation-index.html?project=projA&wp=" + wp_id) in body["data"],
|
||||
ascii_(body["data"], 300))
|
||||
body["to"] == ["sue@example.test"] and "2 lines" in body["text"]
|
||||
and "2026-09-01" in body["text"] and "Shark cage 7" in body["text"]
|
||||
and ("/wp-creation-index.html?project=projA&wp=" + wp_id) in body["text"],
|
||||
ascii_(body["text"], 300))
|
||||
_, ev = api(base, "/api/audit?entity_type=wp&entity_id=%s&action=material_requested" % wp_id, root)
|
||||
chk("...and the audit history has it", bool(ev))
|
||||
|
||||
|
||||
@@ -40,7 +40,8 @@ from sections_check import set_sop # noqa: E40
|
||||
from stepper_check import dismiss_dialogs # noqa: E402
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
CANARY = "FAB-9 SECRET SECTOR" # rides on the package; must never reach a body
|
||||
CANARY = "FAB-9 SECRET SECTOR" # location: customer CONTEXT - IN bodies since 2026-08-20
|
||||
DESC_CANARY = "PULL-SCHED-CANARY-7X" # document CONTENT - must never reach a body
|
||||
|
||||
|
||||
def ascii_(v, n=300):
|
||||
@@ -87,7 +88,7 @@ class SmtpSink(threading.Thread):
|
||||
self.sock.bind(("127.0.0.1", 0))
|
||||
self.sock.listen(8)
|
||||
self.port = self.sock.getsockname()[1]
|
||||
self.messages = [] # {"to": [...], "data": str}
|
||||
self.messages = [] # {"to": [...], "data": wire str, "text": decoded body}
|
||||
self._stop = False
|
||||
|
||||
def run(self):
|
||||
@@ -127,8 +128,22 @@ class SmtpSink(threading.Thread):
|
||||
return
|
||||
if in_data:
|
||||
if line.rstrip(b"\r\n") == b".":
|
||||
raw = b"".join(buf)
|
||||
# "data" is the wire payload (headers + body, transfer-encoded).
|
||||
# "text" is the DECODED body: any non-ASCII character (the
|
||||
# bodies' em-dash) switches smtplib to quoted-printable, whose
|
||||
# soft line breaks split words at column 76 - a substring pin
|
||||
# against "data" then fails on luck of line position. Content
|
||||
# pins read "text"; header pins (Subject:) still read "data".
|
||||
import email as _email
|
||||
try:
|
||||
_msg = _email.message_from_bytes(raw)
|
||||
_text = _msg.get_payload(decode=True).decode("utf-8", "replace")
|
||||
except Exception:
|
||||
_text = raw.decode("utf-8", "replace")
|
||||
self.messages.append({"to": list(rcpt),
|
||||
"data": b"".join(buf).decode("utf-8", "replace")})
|
||||
"data": raw.decode("utf-8", "replace"),
|
||||
"text": _text})
|
||||
rcpt, in_data, buf = [], False, []
|
||||
conn.sendall(b"250 OK\r\n")
|
||||
else:
|
||||
@@ -177,7 +192,7 @@ def set_qa_group(ids):
|
||||
|
||||
def mkwp(base, tok, wp_id, status="In Progress", extra_data=None, assignee=None):
|
||||
data = {"constraints": [{"name": "Boom lift", "status": "cleared", "comment": ""}],
|
||||
"location": CANARY, "desc": "600ft of 3/4 EMT through " + CANARY}
|
||||
"location": CANARY, "desc": "600ft of 3/4 EMT through " + DESC_CANARY}
|
||||
data.update(extra_data or {})
|
||||
return api(base, "/api/wps", tok, "POST", {
|
||||
"id": wp_id, "project_id": "projA", "number": "QA-" + wp_id[-2:],
|
||||
@@ -263,12 +278,16 @@ def main():
|
||||
rcpts = sorted(m["to"][0] for m in qa_msgs())
|
||||
chk("...addressed to the group members and NOBODY else",
|
||||
rcpts == ["pat@example.test", "sue@example.test"], ascii_(rcpts))
|
||||
body = qa_msgs()[0]["data"] if qa_msgs() else ""
|
||||
body = qa_msgs()[0]["text"] if qa_msgs() else ""
|
||||
chk("the message carries the WP number", "QA-A2" in body, ascii_(body, 200))
|
||||
chk("...and a link that opens THAT work package, not the app root",
|
||||
"/wp-creation-index.html?project=projA&wp=wpQA2" in body, ascii_(body, 400))
|
||||
chk("...and no customer IP: the location canary does not appear",
|
||||
CANARY not in body and all(CANARY not in m["data"] for m in sink.messages))
|
||||
# Decided 2026-08-20: context IN, content OUT. This pin asserted the
|
||||
# location's ABSENCE until that decision; it flipped with the rule.
|
||||
chk("...and the location and title ride in the body (context, allowed)",
|
||||
CANARY in body and "conduit" in body, ascii_(body, 300))
|
||||
chk("...but document content never does: the desc canary appears nowhere",
|
||||
all(DESC_CANARY not in m["text"] + m["data"] for m in sink.messages))
|
||||
chk("...and no SMTP password either", "SMTP_PASSWORD" not in body
|
||||
and os.getenv("SMTP_PASSWORD", "hunter2-not-set") not in body)
|
||||
|
||||
@@ -290,7 +309,7 @@ def main():
|
||||
chk("...exactly them", rcpts == ["mix@example.test", "pat@example.test",
|
||||
"sue@example.test"], ascii_(rcpts))
|
||||
chk("...and the comment itself stays on the package, out of the mail",
|
||||
all("Torque strap" not in m["data"] for m in sink.messages))
|
||||
all("Torque strap" not in m["text"] + m["data"] for m in sink.messages))
|
||||
|
||||
# The upsert path enforces the same comment rule (it is how the browser saves).
|
||||
code, _ = api(base, "/api/wps/wpQA2/status", root, "POST", {"status": "Ready for QA"})
|
||||
|
||||
Reference in New Issue
Block a user