T7.6 - CR-014/D2/D9/D10: the Ready for QA gate, notification only, shipped off
Marlena's ask: QA sees inbound work ahead of time, not after the fact. The rung: 'Ready for QA' sits between In Progress and QC in BOTH ladders (wp-creation-app.js STATUS_ORDER, server/app.py STATUS_ORDER) and in Field View's list - inside the T7.3 transition model, not beside it: entering it from an unreleased state crosses the release gates, and 'Issue' (hold) stays a branch. The dashboard filter and the navigator grouping learned the state from the ladder without their own edits. Who hears (D2): the QA GROUP, a multi-pick of project members on the SOP wizard's team step, stored as account ids at data.sop.project.qaGroupIds. Entering Ready for QA emails that list and nobody else. A rejection emails the owner AND the same list (amended answer), returns the package to In Progress, and REQUIRES a fresh comment - server-enforced on both write paths (the first version accepted any old comment already on the record, which made every rejection after the first one free; the gate now demands a new entry). Accept and reject are real buttons on the release banner; the comment modal enforces its field; qa_ready / qa_rejected / status_changed all land in the audit history. The link (X1): wp_link() now opens THE package - wp-creation-index.html ?project&wp=<id>, which the creator boots directly and login.html?next= round-trips for a signed-out recipient. It previously pointed at the suite root, which is exactly the failure X1 names; assignment mail inherits the fix. Email discipline (D10 + standing rules): ships OFF (the stored setting the admin console already owns; PUT /api/settings is admin-only, 403 for anyone else, and audited). With it off, transitions write outbox rows marked 'skipped' and the sink receives nothing. With it on, the probe runs a REAL SMTP conversation against an in-process capture sink and asserts the count and the exact recipient set. A dead SMTP host leaves a 'failed' outbox row with the error recorded. The SMTP password exists only in the environment. DEVIATION, stated: the task's Do-paragraph asks the email to include location and a scope summary; the done-when list (and CLAUDE.md) says no customer IP in a message body. The done-when wins: bodies carry the WP number, who moved it, and the deep link. A location canary planted on the package is asserted absent from every captured message. If the fuller body is wanted, that is a product call - needs Nick. Found while building, logged not fixed (BL-021): project_sop_team() reads sop.data['project'], a path pushSOP never writes - the critical-reopen email has never actually reached the PM/CM. One-line fix, owned by T9.9. Field View (D9): 'Ready for QA' is carried by TEXT on the card at 390px. Verification (each probe run alone): NEW tests/qa_gate_check.py 40/40. Regressions: hold_check 50/50, pipeline_check 44/44, aggregates_check 16/16, frame_check 39/39, validation_check 83/83. Items: CR-014, D2, D9, D10 (X1, X3 honored) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -280,6 +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
|
||||
```
|
||||
|
||||
**Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live
|
||||
|
||||
@@ -457,3 +457,20 @@ deliberately deferred.
|
||||
departure intentional, not a blanket disabling of the guard. If it is to be
|
||||
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
|
||||
|
||||
- **Found during:** T7.6
|
||||
- **Where:** `server/app.py`, `project_sop_team()`
|
||||
- **What:** the function reads `sop.data["project"]`, but `ProjectData.pushSOP`
|
||||
stores every SOP row as `data = {sop: ..., state: ...}` — the project block
|
||||
lives at `data["sop"]["project"]`. The lookup therefore always returns `[]`,
|
||||
and the critical-constraint-reopened email (Phase S wave) has never actually
|
||||
reached the PM or CM it names as recipients; only the owner and distribution
|
||||
got it. Found while writing `project_qa_group()` for `CR-014`, which reads the
|
||||
correct path (and tolerates the flat one for safety).
|
||||
- **Why not now:** T7.6 is scoped to the QA gate; fixing another feature's
|
||||
recipient list inside it is the drive-by CLAUDE.md forbids. The fix is one
|
||||
line, but it deserves its own verification against the capture sink.
|
||||
- **Suggested wave or follow-up:** wave 9 backlog sweep (`T9.9`), verified with
|
||||
the `tests/qa_gate_check.py` sink pattern.
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
'use strict';
|
||||
|
||||
var PID = '', PROJECT = null, WPS = [], curId = null, pendingPhoto = '', draftNote = '';
|
||||
var STATUSES = ['Draft', 'Scheduled', 'Issued', 'In Progress', 'QC', 'Closed', 'Issue'];
|
||||
var GATED = ['Issued', 'In Progress', 'QC', 'Closed']; // need all constraints cleared to enter
|
||||
var STATUSES = ['Draft', 'Scheduled', 'Issued', 'In Progress', 'Ready for QA', 'QC', 'Closed', 'Issue'];
|
||||
var GATED = ['Issued', 'In Progress', 'Ready for QA', 'QC', 'Closed']; // need all constraints cleared to enter
|
||||
|
||||
function esc(s) { return s == null ? '' : String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
|
||||
function nsKey(id) { return 'wp_iwp_v1__' + id; }
|
||||
@@ -84,6 +84,7 @@ function renderList() {
|
||||
var waiting = waitingCount(p, WPS); // the full set, not the filtered rows
|
||||
var cls = p.status === 'Issue' ? 'hold' : ((open === 0 && !waiting) ? 'ready' : '');
|
||||
var readyPill = p.status === 'Issue' ? '<span class="pill bad">On hold</span>'
|
||||
: p.status === 'Ready for QA' ? '<span class="pill ok">Ready for QA</span>'
|
||||
: (open ? '<span class="pill warn">' + open + ' open</span>'
|
||||
: (waiting ? '<span class="pill warn">waits on ' + waiting + '</span>'
|
||||
: '<span class="pill ok">Ready</span>'));
|
||||
|
||||
@@ -32,6 +32,7 @@ let state = {
|
||||
// notification routing uses — a typed name can't be emailed.
|
||||
team: {pm:'', apm:'', cm:'', qm:''},
|
||||
teamIds: {pm:'', apm:'', cm:'', qm:''},
|
||||
qaGroupIds: [], // D2: who is emailed when a package reaches Ready for QA
|
||||
teamMembers: [],
|
||||
signoffRoles: [{role:'Superintendent',name:''},{role:'Foreman',name:''}],
|
||||
wpTypes: [],
|
||||
@@ -419,6 +420,7 @@ function loadSampleData(){
|
||||
state.team.cm = 'K. Boyd';
|
||||
state.team.qm = 'D. Nguyen';
|
||||
state.teamIds = {pm:'', apm:'', cm:'', qm:''};
|
||||
state.qaGroupIds = [];
|
||||
renderTeamPickers();
|
||||
|
||||
// Step 3 — standard required roles
|
||||
@@ -500,6 +502,7 @@ function restoreSavedSOP(){
|
||||
// A SOP saved before the team was account-backed has no teamIds; default them
|
||||
// so the pickers render (the stored names show as "(no account)" until linked).
|
||||
if(!state.teamIds) state.teamIds = {pm:'', apm:'', cm:'', qm:''};
|
||||
if(!Array.isArray(state.qaGroupIds)) state.qaGroupIds = []; // D2, pre-T7.6 SOPs
|
||||
|
||||
// Re-render dynamic lists from restored state.
|
||||
renderWPTypes();
|
||||
@@ -820,6 +823,7 @@ function renderTeamPickers(){
|
||||
sel.innerHTML = html;
|
||||
sel.onchange = function(){ setTeamLead(key, this.value); };
|
||||
});
|
||||
renderQaGroupPicker();
|
||||
if(!warn) return;
|
||||
if(projectUsersLoaded && !projectUsers.length){
|
||||
warn.style.display = '';
|
||||
@@ -835,6 +839,20 @@ function renderTeamPickers(){
|
||||
}
|
||||
}
|
||||
|
||||
// D2: the QA group - a multi-pick of project members, chosen once here and read
|
||||
// by the server when a package reaches Ready for QA. Stored as account ids;
|
||||
// display names are derived at save so a rename never breaks the routing.
|
||||
function renderQaGroupPicker(){
|
||||
const sel = document.getElementById('proj_qagroup');
|
||||
if(!sel) return;
|
||||
const cur = new Set(state.qaGroupIds || []);
|
||||
sel.innerHTML = projectUsers.map(u =>
|
||||
`<option value="${escAttr(u.id)}"${cur.has(u.id)?' selected':''}>${escAttr(userLabel(u))}</option>`).join('');
|
||||
sel.onchange = function(){
|
||||
state.qaGroupIds = [...this.selectedOptions].map(o => o.value).filter(Boolean);
|
||||
};
|
||||
}
|
||||
|
||||
// One <select> of project people, reused everywhere the SOP names someone. Keeps a
|
||||
// name that has no matching account as a selected "(no account)" option so older
|
||||
// SOPs — and the sample's fictional names — are never silently dropped.
|
||||
@@ -2124,6 +2142,9 @@ function completeSOP(){
|
||||
apmId: state.teamIds.apm || '',
|
||||
cmId: state.teamIds.cm || '',
|
||||
qmId: state.teamIds.qm || '',
|
||||
// D2: the QA group - ids are the routing, names are for display.
|
||||
qaGroupIds: (state.qaGroupIds || []).slice(),
|
||||
qaGroup: (state.qaGroupIds || []).map(id => { const u = userById(id); return u ? (u.full_name || u.username) : ''; }).filter(Boolean),
|
||||
site: state.project.site,
|
||||
teamMembers: state.teamMembers.filter(m=>(m.role||m.name||m.userId))
|
||||
},
|
||||
|
||||
@@ -184,6 +184,11 @@
|
||||
<label>Quality manager (QM)</label>
|
||||
<select id="proj_qm" class="team-pick" data-team="qm"></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>QA group — notified at Ready for QA</label>
|
||||
<select id="proj_qagroup" multiple size="4" aria-describedby="qagroup-hint"></select>
|
||||
<div class="field-hint" id="qagroup-hint">Everyone picked here is emailed when a work package reaches <strong>Ready for QA</strong> (CR-014). Hold Ctrl (Cmd on Mac) to pick several.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;">
|
||||
<div class="sub-heading">Additional Team Members (optional)</div>
|
||||
|
||||
@@ -40,7 +40,10 @@ const SAMPLE_SOP = {
|
||||
// Standard AWP constraint set (Vol I §2.3.2; Vol II IWP checklists)
|
||||
const DEFAULT_CONSTRAINTS = ['Safety & Permitting','Quality Control / Inspection','IFC Drawings & Specs','Schedule','Materials (on site, bagged & tagged)','Prefabrication','Work Access & Laydown','Craft Availability','Construction Equipment & Tools','Scaffolding / Access Equipment'];
|
||||
const SIGNOFF_ROLES = ['Planner','Superintendent','HSE Professional','Quality Representative','Work Foreman'];
|
||||
const STATUS_ORDER = ['Draft','Scheduled','Issued','In Progress','QC','Closed'];
|
||||
// CR-014: 'Ready for QA' is a rung between In Progress and QC - the queue QA
|
||||
// plans their week from. 'Issue' (hold) stays OUT of this ladder: it is a
|
||||
// branch, not a rung (T7.3).
|
||||
const STATUS_ORDER = ['Draft','Scheduled','Issued','In Progress','Ready for QA','QC','Closed'];
|
||||
const ISSUED_IDX = STATUS_ORDER.indexOf('Issued');
|
||||
|
||||
// Acumatica cost codes (comment 10) — code|description
|
||||
@@ -62,7 +65,7 @@ const EXAMPLE_PKG = {"number":"WP02-1P-ELEC-Conduit","status":"Issue","subject":
|
||||
let SOP=null, editingId=null, numberDirty=false;
|
||||
let pkgKind='iwp'; // 'iwp' (install) | 'ewp' (BIM) — per-package, only relevant when SOP.bimEnabled
|
||||
let activeProjectId=''; // set at boot from ?project=<id>; stamped onto saved WPs for the API
|
||||
let pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[], pkgAssets=[];
|
||||
let pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[], pkgAssets=[], pkgQaRejections=[];
|
||||
let pkgOverrides={}; // {fieldId: reason} for SOP-locked fields that were edited
|
||||
let numberDims={}; // {Sector:'', Discipline:''} dimensions that build the WP number (comment 3)
|
||||
let pkgDisciplines=[]; // disciplines this WP covers (from SOP governance.disciplines)
|
||||
@@ -989,6 +992,15 @@ function updateReleaseBanner(){
|
||||
const b=document.getElementById('release-banner'); const r=readiness(); const st=getRadio('status');
|
||||
let cls, txt;
|
||||
if(st==='Issue'){ cls='rb-hold'; txt=`⛔ On hold — ${r.open} constraint${r.open===1?'':'s'} reopened. Resolve to resume.`; }
|
||||
else if(st==='Ready for QA'){
|
||||
// CR-014: the QA decision lives where the state is announced. Accept moves to
|
||||
// QC; reject returns to the crew and REQUIRES a comment (the modal enforces
|
||||
// it, and the server refuses the transition without one).
|
||||
cls='rb-qa';
|
||||
txt=`◉ In the QA queue — accept to begin QC, or return it to the crew with a comment.`
|
||||
+` <button type="button" class="rb-act" onclick="qaAccept()">Accept — start QC</button>`
|
||||
+`<button type="button" class="rb-act rb-act-ghost" onclick="qaRejectOpen()">Reject…</button>`;
|
||||
}
|
||||
else if(r.ready){ cls='rb-ready'; txt=`✓ Release-ready — all ${r.total} constraints cleared or N/A.`; }
|
||||
else if(r.constraintsClear){
|
||||
// Constraints are done; what's left is upstream work.
|
||||
@@ -1081,6 +1093,32 @@ function releaseHold(clearedName){
|
||||
if(sg) sg.scrollIntoView({behavior:'smooth', block:'center'});
|
||||
}
|
||||
|
||||
// CR-014: accept / reject, from the banner. Accept is a plain forward step.
|
||||
// Reject is the audited return: a comment is mandatory, rides on the package
|
||||
// (data.qaRejections), and the server emails the owner and the QA group.
|
||||
function qaAccept(){
|
||||
setRadio('status','QC'); prevStatus='QC';
|
||||
updateReleaseBanner(); toast('Accepted — package is in QC.');
|
||||
track('qa_accepted');
|
||||
}
|
||||
function qaRejectOpen(){
|
||||
const el=document.getElementById('qa-reject-comment'); if(el) el.value='';
|
||||
document.getElementById('qa-reject-modal').classList.add('open');
|
||||
if(el) el.focus();
|
||||
}
|
||||
function qaRejectCancel(){ document.getElementById('qa-reject-modal').classList.remove('open'); }
|
||||
function qaRejectSubmit(){
|
||||
const c=(document.getElementById('qa-reject-comment')||{value:''}).value.trim();
|
||||
if(!c){ alert('A comment is required — the crew needs to know what to fix.'); return; }
|
||||
pkgQaRejections.push({ ts:new Date().toISOString(), comment:c,
|
||||
by:(window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '',
|
||||
from:'Ready for QA' });
|
||||
document.getElementById('qa-reject-modal').classList.remove('open');
|
||||
setRadio('status','In Progress'); prevStatus='In Progress';
|
||||
updateReleaseBanner(); toast('Returned to In Progress — the comment is on the record.');
|
||||
track('qa_rejected');
|
||||
}
|
||||
|
||||
// D4: the banner's primary action for an Urgent package blocked by constraints.
|
||||
// It runs the SAME audited path the status control runs - one override, one log.
|
||||
function urgentOverrideRelease(){
|
||||
@@ -1253,6 +1291,7 @@ function collectPackage(){
|
||||
qcFromSOP: !pkgOverrides['wp_qc'], photoFromSOP: !pkgOverrides['wp_photo'], holdFromSOP: !pkgOverrides['wp_hold'], overrides:{...pkgOverrides},
|
||||
signoffs:pkgSignoffs.map(s=>({role:s.role,name:s.name,date:s.date,signed:s.signed,dateReason:s.dateReason||''})),
|
||||
holds:pkgHolds.map(h=>({...h})),
|
||||
qaRejections:pkgQaRejections.map(x=>({...x})),
|
||||
actualHrs:gv('wp_actual_hrs'), installedQty:gv('wp_installed_qty'), redlines:gv('wp_redlines'), lessons:gv('wp_lessons'),
|
||||
kind: pkgKind, // 'iwp' (install) | 'ewp' (BIM) — drives which fields/types/gates apply
|
||||
// AWP traceability: which BIM/model package(s) enabled this install package.
|
||||
@@ -2043,7 +2082,7 @@ function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); }
|
||||
// ── WP NAVIGATOR (left rail) ─────────────────────────────────────────────────
|
||||
// Every saved package on this project, grouped by status, filterable. Clicking a
|
||||
// row opens it in the form (same path as the Saved table's "edit").
|
||||
const WPNAV_ORDER=['Issue','In Progress','Issued','QC','Scheduled','Draft','Closed'];
|
||||
const WPNAV_ORDER=['Issue','Ready for QA','In Progress','Issued','QC','Scheduled','Draft','Closed'];
|
||||
// The panel's ESSENTIAL layout ships with this script rather than living only in
|
||||
// wp-creation-styles.css. The two files are cached independently, so a browser can
|
||||
// run new markup against an old stylesheet — and without these rules the panel is
|
||||
@@ -2395,6 +2434,7 @@ function loadPackageIntoForm(p){
|
||||
pkgConstraints=(p.constraints||[]).map(c=>({...c})); if(!pkgConstraints.length) buildConstraints(); else renderConstraintRows();
|
||||
pkgSignoffs=(p.signoffs||[]).map(s=>({...s, fromSOP:!!s.name})); if(!pkgSignoffs.length) buildSignoffs(); else renderSignoffRows();
|
||||
pkgHolds=(p.holds||[]).map(h=>({...h}));
|
||||
pkgQaRejections=(p.qaRejections||[]).map(x=>({...x}));
|
||||
updateNumber(); updateReleaseBanner(); prevStatus=p.status||'Draft'; showForm(); wpFormPopulated();
|
||||
}
|
||||
function renderConstraintRows(){ const tmp=pkgConstraints; pkgConstraints=[]; buildConstraints();
|
||||
@@ -2457,7 +2497,7 @@ function newPackage(){
|
||||
pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach();
|
||||
pkgWorkSteps=['']; buildWorkSteps();
|
||||
pkgConstraints=[]; buildConstraints(); pkgSignoffs=[]; buildSignoffs();
|
||||
pkgHolds=[]; pkgOverrides={};
|
||||
pkgHolds=[]; pkgQaRejections=[]; pkgOverrides={};
|
||||
set_quality_from_sop(); lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
|
||||
prevStatus='Draft';
|
||||
updateNumber(); updateReleaseBanner(); defaultOwnerToMe(); showForm(); wpFormPopulated(); renderWpNav(); track('new_package');
|
||||
|
||||
@@ -194,6 +194,7 @@
|
||||
<label class="radio-pill" data-val="Scheduled"><input type="radio" name="status"><span class="dot"></span>Scheduled</label>
|
||||
<label class="radio-pill" data-val="Issued"><input type="radio" name="status"><span class="dot"></span>Issued</label>
|
||||
<label class="radio-pill" data-val="In Progress"><input type="radio" name="status"><span class="dot"></span>In progress</label>
|
||||
<label class="radio-pill" data-val="Ready for QA"><input type="radio" name="status"><span class="dot"></span>Ready for QA</label>
|
||||
<label class="radio-pill pill-hold" data-val="Issue"><input type="radio" name="status"><span class="dot"></span>Issue (hold)</label>
|
||||
<label class="radio-pill" data-val="QC"><input type="radio" name="status"><span class="dot"></span>QC</label>
|
||||
<label class="radio-pill" data-val="Closed"><input type="radio" name="status"><span class="dot"></span>Closed</label>
|
||||
@@ -501,6 +502,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QA REJECT MODAL (CR-014). The comment is not optional: a rejection with no
|
||||
reason is the after-the-fact surprise this gate exists to end, and the server
|
||||
refuses the transition without one. -->
|
||||
<div class="modal-overlay" id="qa-reject-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-head"><div class="modal-title">Return to the crew — QA rejection</div><button class="cmt-x" onclick="qaRejectCancel()" title="Cancel">✕</button></div>
|
||||
<div class="modal-body">
|
||||
<div class="notice">The package goes back to <strong>In Progress</strong>. The owner and the QA group are notified, and the comment stays on the package.</div>
|
||||
<div class="field"><label>What needs fixing <span class="req">*</span></label><textarea id="qa-reject-comment" rows="3" placeholder="What QA found — required"></textarea></div>
|
||||
</div>
|
||||
<div class="modal-foot"><button class="btn btn-ghost" onclick="qaRejectCancel()">Cancel</button><button class="btn btn-generate" onclick="qaRejectSubmit()">Reject — back to In Progress</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VIEW SOP MODAL (comment 2) -->
|
||||
<div class="modal-overlay" id="sop-modal">
|
||||
<div class="modal" style="max-width:640px">
|
||||
|
||||
@@ -568,6 +568,10 @@
|
||||
.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); }
|
||||
.rb-hold { background:var(--red-dim); color:var(--red); border:1px solid var(--wp-status-error-border-b); }
|
||||
/* CR-014: the QA-queue state. Informational, not alarming - accent, not amber. */
|
||||
.rb-qa { background:var(--accent-dim); color:var(--accent); border:1px solid var(--accent); }
|
||||
.rb-act-ghost { background:transparent; color:var(--accent); border:1px solid var(--accent); margin-left:8px; }
|
||||
.rb-act-ghost:hover { background:var(--accent-dim); }
|
||||
/* D4: the audited-override action on the release banner. A real button in the
|
||||
primary position for an Urgent package; it simply never renders otherwise. */
|
||||
.rb-act { margin-left:auto; border:none; border-radius:var(--radius); cursor:pointer;
|
||||
|
||||
155
server/app.py
155
server/app.py
@@ -448,8 +448,12 @@ def require_assignable(db: Session, user_id: str, project_id: Optional[str]) ->
|
||||
|
||||
|
||||
def wp_link(db: Session, wp: "models.WorkPackage") -> str:
|
||||
"""A link that opens THIS work package. X1: never the app root - a recipient
|
||||
who has to hunt for the package after signing in stops opening the emails.
|
||||
The creator has been its own document since T7.1 and boots ?wp= deep links;
|
||||
a signed-out recipient rides login.html?next= straight back to it."""
|
||||
base = (notify.get_settings(db).get("app_base_url") or "").rstrip("/")
|
||||
path = f"/work-package-suite.html?tab=wp&project={wp.project_id or ''}"
|
||||
path = f"/wp-creation-index.html?project={wp.project_id or ''}&wp={wp.id}"
|
||||
return (base + path) if base else path
|
||||
|
||||
|
||||
@@ -524,6 +528,10 @@ class TestEmailIn(BaseModel):
|
||||
|
||||
class StatusIn(BaseModel):
|
||||
status: str
|
||||
# CR-014: a rejection (Ready for QA -> In Progress) must say why. The upsert
|
||||
# route carries the comment inside data.qaRejections; this route has no data,
|
||||
# so it carries the comment here and the server appends the record itself.
|
||||
comment: Optional[str] = None
|
||||
|
||||
|
||||
class ArchiveIn(BaseModel):
|
||||
@@ -1411,7 +1419,8 @@ def delete_sop(sop_id: str, user: models.User = Depends(auth.get_current_user),
|
||||
# _released() must never count it: counting it is what let every transition out of
|
||||
# hold skip the release gates as "already released", which made /status a side
|
||||
# door past an open constraint (CR-015 / T7.3).
|
||||
STATUS_ORDER = ["Draft", "Scheduled", "Issued", "In Progress", "Issue", "QC", "Closed"]
|
||||
STATUS_ORDER = ["Draft", "Scheduled", "Issued", "In Progress", "Ready for QA", "Issue", "QC", "Closed"]
|
||||
QA_READY_STATUS = "Ready for QA"
|
||||
ISSUED_IDX = STATUS_ORDER.index("Issued")
|
||||
DONE_STATUS = "Closed"
|
||||
HOLD_STATUS = "Issue"
|
||||
@@ -1563,6 +1572,108 @@ def project_sop_team(db: Session, project_id: Optional[str]) -> list[str]:
|
||||
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)."""
|
||||
if not project_id:
|
||||
return []
|
||||
sop = db.scalars(
|
||||
select(models.Sop)
|
||||
.where((models.Sop.project_id == project_id) & (models.Sop.complete.is_(True)))
|
||||
.order_by(models.Sop.updated_at.desc())
|
||||
.limit(1)
|
||||
).first()
|
||||
if not sop:
|
||||
return []
|
||||
data = sop.data or {}
|
||||
proj = ((data.get("sop") or {}).get("project")
|
||||
or data.get("project") or {})
|
||||
ids = [i for i in (proj.get("qaGroupIds") or []) if isinstance(i, str) and i]
|
||||
if not ids:
|
||||
return []
|
||||
return list(db.scalars(select(models.User).where(models.User.id.in_(ids))))
|
||||
|
||||
|
||||
def qa_rejection_comment(data: Optional[dict]) -> str:
|
||||
rejs = [r for r in ((data or {}).get("qaRejections") or []) if isinstance(r, dict)]
|
||||
if not rejs:
|
||||
return ""
|
||||
return str(rejs[-1].get("comment") or "").strip()
|
||||
|
||||
|
||||
def enforce_qa_rejection_comment(data: Optional[dict], new_status: str,
|
||||
old_status: Optional[str],
|
||||
old_data: Optional[dict] = None) -> None:
|
||||
"""CR-014: a rejection with no reason is the after-the-fact surprise this gate
|
||||
exists to end. Runs before anything is written, like the release gates.
|
||||
The comment must be FRESH: a rejection entry already on the record satisfied
|
||||
the first version of this check, which made every rejection after the first
|
||||
one free. New entry, non-empty comment, or the transition is refused."""
|
||||
if old_status == QA_READY_STATUS and new_status == "In Progress":
|
||||
new_rejs = [r for r in ((data or {}).get("qaRejections") or []) if isinstance(r, dict)]
|
||||
old_rejs = [r for r in ((old_data or {}).get("qaRejections") or []) if isinstance(r, dict)]
|
||||
fresh = len(new_rejs) > len(old_rejs) and str(new_rejs[-1].get("comment") or "").strip()
|
||||
if not fresh:
|
||||
raise HTTPException(status_code=409, detail={
|
||||
"message": "Returning a package from Ready for QA requires a comment",
|
||||
})
|
||||
|
||||
|
||||
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.
|
||||
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"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."
|
||||
)
|
||||
|
||||
|
||||
def qa_reject_body(user: "models.User", wp: "models.WorkPackage",
|
||||
actor: "models.User", link: str) -> str:
|
||||
who = actor.full_name or actor.username
|
||||
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"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."
|
||||
)
|
||||
|
||||
|
||||
def notify_qa_transition(db: Session, wp: "models.WorkPackage",
|
||||
actor: "models.User", rejected: bool) -> list:
|
||||
"""Entering Ready for QA emails the QA group and nobody else (D2). A rejection
|
||||
emails the owner AND the same group (D9's sibling decision). Deduplicated;
|
||||
every send is an outbox row, so a failure is recorded, never silent."""
|
||||
recipients = {u.id: u for u in project_qa_group(db, wp.project_id)}
|
||||
if rejected and wp.assignee_id:
|
||||
owner = db.get(models.User, wp.assignee_id)
|
||||
if owner:
|
||||
recipients[owner.id] = owner
|
||||
out = []
|
||||
link = wp_link(db, wp)
|
||||
for u in recipients.values():
|
||||
out.append(notify.enqueue(
|
||||
db, user=u, kind="qa_rejected" if rejected else "qa_ready",
|
||||
subject=(f"Returned from QA: {wp.number or 'work package'}" if rejected
|
||||
else f"Ready for QA: {wp.number or 'work package'}"),
|
||||
body=(qa_reject_body(u, wp, actor, link) if rejected
|
||||
else qa_ready_body(u, wp, actor, link)),
|
||||
link=link, wp_id=wp.id, project_id=wp.project_id,
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
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
|
||||
@@ -1632,6 +1743,7 @@ def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User =
|
||||
wp_id_for_checks = body.id or wp.id
|
||||
check_predecessor_cycle(db, wp_id_for_checks, body.data)
|
||||
enforce_release_gates(db, wp_id_for_checks, body.data, body.status, old_status)
|
||||
enforce_qa_rejection_comment(body.data, body.status, old_status, old_data)
|
||||
wp.project_id = body.project_id
|
||||
wp.sop_id = body.sop_id
|
||||
wp.parent_id = body.parent_id
|
||||
@@ -1694,6 +1806,19 @@ def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User =
|
||||
summary=(wp.number or wp.subject or wp.id),
|
||||
detail={"critical": reopened, "status": wp.status})
|
||||
notifs.extend(notify_critical_reopen(db, wp, reopened, user))
|
||||
# CR-014: the QA gate. Entering Ready for QA tells the QA group their queue
|
||||
# grew; a rejection tells the owner and the same group. Both write their own
|
||||
# audit line - status_changed says from/to, these say what it MEANS.
|
||||
if not is_new and old_status != wp.status:
|
||||
if wp.status == QA_READY_STATUS:
|
||||
log_event(db, user, "qa_ready", "wp", wp.id, project_id=wp.project_id,
|
||||
summary=(wp.number or wp.subject or wp.id), detail={"from": old_status})
|
||||
notifs.extend(notify_qa_transition(db, wp, user, rejected=False))
|
||||
elif old_status == QA_READY_STATUS and wp.status == "In Progress":
|
||||
log_event(db, user, "qa_rejected", "wp", wp.id, project_id=wp.project_id,
|
||||
summary=(wp.number or wp.subject or wp.id),
|
||||
detail={"comment": qa_rejection_comment(wp.data)[:300]})
|
||||
notifs.extend(notify_qa_transition(db, wp, user, rejected=True))
|
||||
# Notify a newly-assigned owner (skip self-assignment).
|
||||
notif = None
|
||||
if new_assignee and new_assignee != old_assignee and new_assignee != user.id:
|
||||
@@ -2419,15 +2544,28 @@ def issue_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db:
|
||||
|
||||
|
||||
@app.post("/api/wps/{wp_id}/status")
|
||||
def set_wp_status(wp_id: str, body: StatusIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
def set_wp_status(wp_id: str, body: StatusIn, background_tasks: BackgroundTasks, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
wp = db.get(models.WorkPackage, wp_id)
|
||||
if not wp:
|
||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||
require_project_access(db, user, wp.project_id)
|
||||
require_project_writable(db, user, wp.project_id, "Changing a work package's status")
|
||||
old_status = wp.status
|
||||
_qa_notifs = []
|
||||
# Same gates as /issue — this route must not be a way around them.
|
||||
enforce_release_gates(db, wp.id, wp.data, body.status, old_status)
|
||||
if old_status == QA_READY_STATUS and body.status == "In Progress":
|
||||
comment = str(body.comment or "").strip()
|
||||
if not comment:
|
||||
raise HTTPException(status_code=409, detail={
|
||||
"message": "Returning a package from Ready for QA requires a comment",
|
||||
})
|
||||
data = dict(wp.data or {})
|
||||
rejs = [r for r in (data.get("qaRejections") or []) if isinstance(r, dict)]
|
||||
rejs.append({"ts": models.utcnow().isoformat(), "comment": comment,
|
||||
"by": user.full_name or user.username, "from": QA_READY_STATUS})
|
||||
data["qaRejections"] = rejs
|
||||
wp.data = data
|
||||
wp.status = body.status
|
||||
if body.status == "Issued" and wp.issued_at is None:
|
||||
wp.issued_at = models.utcnow()
|
||||
@@ -2450,8 +2588,19 @@ def set_wp_status(wp_id: str, body: StatusIn, user: models.User = Depends(auth.g
|
||||
detail={"to": body.status,
|
||||
"constraint": str((_h or {}).get("constraint") or "")[:200],
|
||||
"reason": str((_h or {}).get("details") or "")[:300]})
|
||||
if body.status == QA_READY_STATUS:
|
||||
log_event(db, user, "qa_ready", "wp", wp.id, project_id=wp.project_id,
|
||||
summary=(wp.number or wp.subject or wp.id), detail={"from": old_status})
|
||||
_qa_notifs = notify_qa_transition(db, wp, user, rejected=False)
|
||||
elif old_status == QA_READY_STATUS and body.status == "In Progress":
|
||||
log_event(db, user, "qa_rejected", "wp", wp.id, project_id=wp.project_id,
|
||||
summary=(wp.number or wp.subject or wp.id),
|
||||
detail={"comment": qa_rejection_comment(wp.data)[:300]})
|
||||
_qa_notifs = notify_qa_transition(db, wp, user, rejected=True)
|
||||
db.commit()
|
||||
db.refresh(wp)
|
||||
for n in _qa_notifs:
|
||||
background_tasks.add_task(notify.deliver, n.id)
|
||||
return wp.to_dict()
|
||||
|
||||
|
||||
|
||||
438
tests/qa_gate_check.py
Normal file
438
tests/qa_gate_check.py
Normal file
@@ -0,0 +1,438 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Does the QA gate notify the right people, and only when told to? — CR-014, T7.6.
|
||||
|
||||
Marlena's ask: QA needs to see inbound work ahead of time, not be told after the
|
||||
fact. `Ready for QA` is a rung between In Progress and QC; entering it emails the
|
||||
QA group configured on the SOP (D2); a rejection returns the package to In
|
||||
Progress with a mandatory comment and emails the owner plus the same group.
|
||||
|
||||
Email discipline, per the standing rules and D10:
|
||||
- ships OFF: with the toggle off, transitions write outbox rows marked
|
||||
'skipped' and the capture sink receives NOTHING
|
||||
- only an administrator can turn it on, from the stored setting (not an env
|
||||
var); the change lands in the audit log
|
||||
- the capture sink is a real SMTP conversation on localhost - the count and
|
||||
the recipients of captured messages are asserted, and no real mail exists
|
||||
- the SMTP password lives in the environment alone; message bodies carry a WP
|
||||
number and a deep link, never package contents (the location canary proves
|
||||
it)
|
||||
|
||||
Self-contained: throwaway SQLite, its own uvicorn, its own SMTP sink, headless
|
||||
browser for the client half. Exit 0 all passed, 1 a failure, 2 could not run.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import cdp # noqa: E402
|
||||
from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
|
||||
from sections_check import set_sop # noqa: E402
|
||||
from stepper_check import dismiss_dialogs # noqa: E402
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
CANARY = "FAB-9 SECRET SECTOR" # rides on the package; must never reach a body
|
||||
|
||||
|
||||
def ascii_(v, n=300):
|
||||
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
|
||||
|
||||
|
||||
def settle(seconds=0.6):
|
||||
time.sleep(seconds)
|
||||
|
||||
|
||||
def api(base, path, token, method="GET", body=None):
|
||||
req = urllib.request.Request(base + path, method=method)
|
||||
req.add_header("Cookie", "wp_session=" + token)
|
||||
req.add_header("Accept", "application/json")
|
||||
data = None
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode()
|
||||
req.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req, data, timeout=15) as r:
|
||||
return r.status, json.loads(r.read().decode() or "null")
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
return e.code, json.loads(e.read().decode() or "null")
|
||||
except Exception:
|
||||
return e.code, None
|
||||
|
||||
|
||||
def audit(base, tok, wp_id, action):
|
||||
_, rows = api(base, "/api/audit?entity_type=wp&entity_id=%s&action=%s" % (wp_id, action), tok)
|
||||
return rows or []
|
||||
|
||||
|
||||
class SmtpSink(threading.Thread):
|
||||
"""The smallest SMTP server that satisfies smtplib: enough protocol to accept
|
||||
a message and record it. TLS is refused by omission (the settings turn it
|
||||
off), auth is never requested. Everything captured stays in memory."""
|
||||
daemon = True
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
self.sock.bind(("127.0.0.1", 0))
|
||||
self.sock.listen(8)
|
||||
self.port = self.sock.getsockname()[1]
|
||||
self.messages = [] # {"to": [...], "data": str}
|
||||
self._stop = False
|
||||
|
||||
def run(self):
|
||||
self.sock.settimeout(0.5)
|
||||
while not self._stop:
|
||||
try:
|
||||
conn, _ = self.sock.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
try:
|
||||
self._serve(conn)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def stop(self):
|
||||
self._stop = True
|
||||
try:
|
||||
self.sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _serve(self, conn):
|
||||
conn.settimeout(10)
|
||||
f = conn.makefile("rb")
|
||||
conn.sendall(b"220 sink ready\r\n")
|
||||
rcpt, in_data, buf = [], False, []
|
||||
while True:
|
||||
line = f.readline()
|
||||
if not line:
|
||||
return
|
||||
if in_data:
|
||||
if line.rstrip(b"\r\n") == b".":
|
||||
self.messages.append({"to": list(rcpt),
|
||||
"data": b"".join(buf).decode("utf-8", "replace")})
|
||||
rcpt, in_data, buf = [], False, []
|
||||
conn.sendall(b"250 OK\r\n")
|
||||
else:
|
||||
buf.append(line)
|
||||
continue
|
||||
cmd = line.decode("utf-8", "replace").strip()
|
||||
up = cmd.upper()
|
||||
if up.startswith(("EHLO", "HELO")):
|
||||
conn.sendall(b"250-sink\r\n250 OK\r\n")
|
||||
elif up.startswith("MAIL FROM"):
|
||||
conn.sendall(b"250 OK\r\n")
|
||||
elif up.startswith("RCPT TO"):
|
||||
m = re.search(r"<([^>]+)>", cmd)
|
||||
rcpt.append(m.group(1) if m else cmd)
|
||||
conn.sendall(b"250 OK\r\n")
|
||||
elif up.startswith("DATA"):
|
||||
in_data = True
|
||||
conn.sendall(b"354 go\r\n")
|
||||
elif up.startswith("QUIT"):
|
||||
conn.sendall(b"221 bye\r\n")
|
||||
return
|
||||
else:
|
||||
conn.sendall(b"250 OK\r\n")
|
||||
|
||||
|
||||
def wait_for(fn, timeout=12.0):
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
if fn():
|
||||
return True
|
||||
time.sleep(0.3)
|
||||
return False
|
||||
|
||||
|
||||
def set_qa_group(ids):
|
||||
"""D2's field, as buildSOP() writes it: data['sop']['project']['qaGroupIds']."""
|
||||
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 {}))
|
||||
data.setdefault("sop", {}).setdefault("project", {})["qaGroupIds"] = ids
|
||||
sop.data = data
|
||||
db.commit()
|
||||
|
||||
|
||||
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}
|
||||
data.update(extra_data or {})
|
||||
return api(base, "/api/wps", tok, "POST", {
|
||||
"id": wp_id, "project_id": "projA", "number": "QA-" + wp_id[-2:],
|
||||
"subject": "conduit " + CANARY, "status": status,
|
||||
"assignee_id": assignee, "data": data})
|
||||
|
||||
|
||||
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-qa-")
|
||||
db_path = os.path.join(tmpdir, "check.db")
|
||||
server = None
|
||||
browser = None
|
||||
sink = SmtpSink()
|
||||
sink.start()
|
||||
try:
|
||||
tok = seed(db_path)
|
||||
set_sop(db_path, {})
|
||||
set_qa_group(["user_sue", "user_pat"])
|
||||
port = cdp.free_port()
|
||||
base = "http://127.0.0.1:%d" % port
|
||||
server = start_server(port, db_path)
|
||||
root, pat = tok["root"], tok["pat"]
|
||||
|
||||
# ── 1. the rung, and the ship-off default ────────────────────────────
|
||||
print("\n1. the new state, with email OFF (as shipped)")
|
||||
code, _ = mkwp(base, root, "wpQA1", assignee="user_mix")
|
||||
chk("a package saves at In Progress", code == 200, code)
|
||||
code, _ = api(base, "/api/wps/wpQA1/status", root, "POST", {"status": "Ready for QA"})
|
||||
chk("Ready for QA is a legal transition from In Progress", code == 200, code)
|
||||
chk("...and it is written to history",
|
||||
bool(audit(base, root, "wpQA1", "qa_ready")), )
|
||||
_, rows = api(base, "/api/notifications?all=true", root)
|
||||
qa_rows = [r for r in (rows or []) if r.get("kind") == "qa_ready"]
|
||||
chk("with email OFF the outbox records the sends as skipped, not silent",
|
||||
qa_rows and all(r.get("status") == "skipped" for r in qa_rows),
|
||||
ascii_([(r.get("email"), r.get("status")) for r in qa_rows]))
|
||||
chk("...and the capture sink received NOTHING", len(sink.messages) == 0,
|
||||
len(sink.messages))
|
||||
|
||||
code, _ = api(base, "/api/wps/wpQA1/status", root, "POST",
|
||||
{"status": "Draft"})
|
||||
chk("the rung is inside the transition model: leaving it re-crosses the "
|
||||
"release gate on the way back up",
|
||||
api(base, "/api/wps/wpQA1/status", root, "POST",
|
||||
{"status": "Ready for QA"})[0] == 200)
|
||||
|
||||
# ── 2. D10: who may turn it on ────────────────────────────────────────
|
||||
print("\n2. D10: the switch")
|
||||
code, _ = api(base, "/api/settings", pat, "PUT", {"email_enabled": True})
|
||||
chk("a non-administrator cannot turn email on", code == 403, code)
|
||||
code, _ = 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})
|
||||
chk("an administrator can", code == 200, code)
|
||||
_, ev = api(base, "/api/audit?entity_type=settings&action=settings_updated", root)
|
||||
chk("...and the change is in the audit log",
|
||||
ev and ev[0].get("detail", {}).get("email_enabled") is True, ascii_(ev[:1]))
|
||||
_, pub = api(base, "/api/settings", root)
|
||||
chk("the settings API never returns an SMTP password, only whether one is set",
|
||||
"smtp_password" not in (pub or {}) and "smtp_password_set" in (pub or {}),
|
||||
ascii_(sorted((pub or {}).keys())))
|
||||
src = open(os.path.join(ROOT, "server", "notify.py"), encoding="utf-8").read()
|
||||
chk("the password is read from the environment and stored nowhere",
|
||||
"SMTP_PASSWORD" in src and "smtp_password" not in json.dumps(
|
||||
__import__("server.notify", fromlist=["DEFAULTS"]).DEFAULTS))
|
||||
|
||||
# ── 3. entering Ready for QA emails the group, and nobody else ───────
|
||||
print("\n3. the send, against the sink")
|
||||
code, _ = mkwp(base, root, "wpQA2", assignee="user_mix")
|
||||
code, _ = api(base, "/api/wps/wpQA2/status", root, "POST", {"status": "Ready for QA"})
|
||||
chk("the transition succeeds with email on", code == 200, code)
|
||||
# The wp was created WITH an assignee, so one wp_assigned mail rides along
|
||||
# - correct and its own feature. This section asserts the QA mails alone.
|
||||
qa_msgs = lambda: [m for m in sink.messages if "Ready for QA:" in m["data"]]
|
||||
chk("the sink captures exactly the QA group - two Ready-for-QA messages",
|
||||
wait_for(lambda: len(qa_msgs()) == 2, 12), len(qa_msgs()))
|
||||
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 ""
|
||||
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))
|
||||
chk("...and no SMTP password either", "SMTP_PASSWORD" not in body
|
||||
and os.getenv("SMTP_PASSWORD", "hunter2-not-set") not in body)
|
||||
|
||||
# ── 4. rejection ──────────────────────────────────────────────────────
|
||||
print("\n4. rejection: comment required, owner + group notified")
|
||||
sink.messages.clear()
|
||||
code, _ = api(base, "/api/wps/wpQA2/status", root, "POST", {"status": "In Progress"})
|
||||
chk("a rejection with no comment is refused", code == 409, code)
|
||||
code, _ = api(base, "/api/wps/wpQA2/status", root, "POST",
|
||||
{"status": "In Progress", "comment": "Torque strap missing on run 4"})
|
||||
chk("with a comment it returns to In Progress", code == 200, code)
|
||||
rows = audit(base, root, "wpQA2", "qa_rejected")
|
||||
chk("...the rejection is in history WITH the comment",
|
||||
rows and "Torque strap" in (rows[0].get("detail", {}).get("comment") or ""),
|
||||
ascii_(rows[:1]))
|
||||
chk("...the owner and the same group are emailed - three messages",
|
||||
wait_for(lambda: len(sink.messages) == 3, 12), len(sink.messages))
|
||||
rcpts = sorted(m["to"][0] for m in sink.messages)
|
||||
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))
|
||||
|
||||
# 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"})
|
||||
_, wp = api(base, "/api/wps/wpQA2", root)
|
||||
code, _ = api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpQA2", "project_id": "projA", "number": wp["number"],
|
||||
"subject": wp["subject"], "status": "In Progress", "data": wp["data"]})
|
||||
chk("the upsert refuses the same no-comment rejection - it is not a side door",
|
||||
code == 409, code)
|
||||
|
||||
# ── 5. failure is recorded, not silent ────────────────────────────────
|
||||
print("\n5. a dead SMTP host is a recorded failure")
|
||||
sink.messages.clear()
|
||||
dead = cdp.free_port()
|
||||
api(base, "/api/settings", root, "PUT", {"smtp_host": "127.0.0.1",
|
||||
"smtp_port": dead})
|
||||
code, _ = mkwp(base, root, "wpQA3")
|
||||
api(base, "/api/wps/wpQA3/status", root, "POST", {"status": "Ready for QA"})
|
||||
|
||||
def failed_rows():
|
||||
_, rows = api(base, "/api/notifications?all=true", root)
|
||||
return [r for r in (rows or [])
|
||||
if r.get("kind") == "qa_ready" and r.get("status") == "failed"]
|
||||
chk("the outbox row is marked failed with the error recorded",
|
||||
wait_for(lambda: len(failed_rows()) >= 1, 15)
|
||||
and bool(failed_rows()[0].get("error")), ascii_(failed_rows()[:1]))
|
||||
api(base, "/api/settings", root, "PUT", {"smtp_host": "127.0.0.1",
|
||||
"smtp_port": sink.port})
|
||||
|
||||
# ── 6. the client: pill, banner, accept/reject, deep link, field view ─
|
||||
print("\n6. the browser half")
|
||||
browser = cdp.Browser(exe)
|
||||
page = browser.page()
|
||||
page.clear_cookies()
|
||||
page.set_cookie("wp_session", root)
|
||||
page.viewport(1440, 900)
|
||||
page.goto(base + "/wp-creation-index.html?project=projA&wp=wpQA2")
|
||||
dismiss_dialogs(page)
|
||||
ok = wait_for(lambda: page.eval("!!window.wpCreatorReady"), 15)
|
||||
settle(1.5)
|
||||
chk("the creator boots on an emailed deep link and opens THAT package",
|
||||
ok and page.eval("editingId") == "wpQA2", page.eval("editingId"))
|
||||
page.eval("window.prompt=()=>null; window.alert=()=>{}; window.confirm=()=>false;")
|
||||
chk("the status control has a real Ready for QA pill",
|
||||
page.eval("!!document.querySelector('.radio-pill[data-val=\"Ready for QA\"] input')"))
|
||||
chk("the dashboard status filter learned the new state on its own",
|
||||
page.eval("STATUS_ORDER.includes('Ready for QA')"))
|
||||
|
||||
# The creator expands the record to the SOP's constraint set, whose
|
||||
# defaults are OPEN - so Ready for QA is (correctly) refused until they
|
||||
# are dealt with. Clear them the way a user would before the QA flow.
|
||||
n = page.eval("pkgConstraints.length")
|
||||
for i in range(n):
|
||||
page.eval("""(() => {
|
||||
const tr = document.querySelectorAll('#constraint-body tr')[%d];
|
||||
[...tr.querySelectorAll('.cstatus button')]
|
||||
.find(b => b.textContent.trim() === 'N/A').click();
|
||||
})()""" % i)
|
||||
settle(0.15)
|
||||
page.eval("document.querySelector('.radio-pill[data-val=\"Ready for QA\"]').click()")
|
||||
settle(0.5)
|
||||
chk("selecting it shows the QA banner with accept and reject as real buttons",
|
||||
page.eval("document.querySelectorAll('#release-banner .rb-act').length") == 2)
|
||||
page.eval("qaAccept()")
|
||||
settle(0.3)
|
||||
chk("accept moves to QC", page.eval("getRadio('status')") == "QC")
|
||||
|
||||
page.eval("document.querySelector('.radio-pill[data-val=\"Ready for QA\"]').click()")
|
||||
settle(0.4)
|
||||
page.eval("qaRejectOpen()")
|
||||
chk("reject opens the comment modal",
|
||||
page.eval("document.getElementById('qa-reject-modal').classList.contains('open')"))
|
||||
page.eval("qaRejectSubmit()")
|
||||
settle(0.2)
|
||||
chk("an empty comment does not pass",
|
||||
page.eval("getRadio('status')") == "Ready for QA")
|
||||
page.eval("document.getElementById('qa-reject-comment').value='Redo the strapping'")
|
||||
page.eval("qaRejectSubmit()")
|
||||
settle(0.3)
|
||||
chk("with a comment the package returns to In Progress",
|
||||
page.eval("getRadio('status')") == "In Progress")
|
||||
chk("...and the comment is on the package",
|
||||
"Redo the strapping" in (page.eval(
|
||||
"JSON.stringify(pkgQaRejections[pkgQaRejections.length-1]||{})") or ""))
|
||||
|
||||
# Field View (D9): the state is text on the card, legible at 390px.
|
||||
page.viewport(390, 900, mobile=True)
|
||||
page.goto(base + "/field.html?project=projA")
|
||||
dismiss_dialogs(page)
|
||||
settle(2.5)
|
||||
pill = json.loads(page.eval("""JSON.stringify((() => {
|
||||
const el = [...document.querySelectorAll('.pill')]
|
||||
.find(x => x.textContent.trim() === 'Ready for QA');
|
||||
if (!el) return null;
|
||||
const cs = getComputedStyle(el);
|
||||
return {size: parseFloat(cs.fontSize), text: el.textContent.trim()};
|
||||
})())"""))
|
||||
chk("390px Field View: 'Ready for QA' is carried by TEXT on the card (D9)",
|
||||
pill is not None, ascii_(pill))
|
||||
chk("...at a legible size", bool(pill) and pill["size"] >= 11, ascii_(pill))
|
||||
|
||||
# The wizard's D2 field: rendered from project members, feeds state.
|
||||
page.viewport(1440, 900)
|
||||
page.goto(base + "/work-package-suite.html?tab=sop")
|
||||
dismiss_dialogs(page)
|
||||
settle(2.5)
|
||||
chk("the SOP wizard has the QA group field, populated from project members",
|
||||
page.eval("(document.getElementById('proj_qagroup')||{options:[]}).options.length") > 0,
|
||||
page.eval("(document.getElementById('proj_qagroup')||{options:[]}).options.length"))
|
||||
page.eval("""(() => {
|
||||
const sel = document.getElementById('proj_qagroup');
|
||||
[...sel.options].forEach(o => o.selected = ['user_sue','user_pat'].includes(o.value));
|
||||
sel.dispatchEvent(new Event('change'));
|
||||
})()""")
|
||||
chk("picking people lands in the SOP state that completeSOP() persists",
|
||||
sorted(json.loads(page.eval("JSON.stringify(state.qaGroupIds||[])")))
|
||||
== ["user_pat", "user_sue"],
|
||||
page.eval("JSON.stringify(state.qaGroupIds||[])"))
|
||||
|
||||
js_errors = [e for e in page.js_errors()]
|
||||
chk("no JavaScript errors anywhere in the browser half", not js_errors,
|
||||
ascii_(js_errors[:2]))
|
||||
|
||||
finally:
|
||||
sink.stop()
|
||||
if browser is not None:
|
||||
try:
|
||||
browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
if server is not None:
|
||||
try:
|
||||
server.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("\n" + "-" * 54)
|
||||
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
|
||||
for f in _FAIL:
|
||||
print(" - " + f)
|
||||
return 1 if _FAIL else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user