diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 84c25ee..1e66586 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -346,6 +346,48 @@ hides the BIM/VDC section and every project is install-only (IWP). A SOP that already has BIM enabled keeps its data — it just stops being offered — so turning the flag off never deletes BIM types, gates, or sequence steps. +## Release gates (constraints + predecessors) + +A work package reaches **Issued** only when both gates are met: + +1. every constraint is **Cleared** or **N/A** — a hard gate, no override; +2. every **predecessor work package** (`data.predecessors`, a list of WP ids) is + **Closed**. + +Enforced by `enforce_release_gates()` on **every** path that can set a status — +`/api/wps` (the browser and the offline outbox both save through it), +`/api/wps/{id}/issue`, and `/api/wps/{id}/status`. Also: + +- **Overridable, deliberately.** Planners legitimately release ahead of upstream + close-out, so the predecessor gate accepts `data.gateOverride = {reason, by, at}`. + A blank reason is not an override. The server writes a `gate_overridden` audit + event naming the reason and what was skipped, and the reason prints on the + package. Changing the predecessor set clears the override. +- **Cycles are refused** (`check_predecessor_cycle`) — direct and through a chain, + with a 400 explaining which package already waits on this one. +- **A deleted predecessor does not block.** It would otherwise freeze everything + downstream of a package someone removed. +- The Creator's picker hides itself and any package that already waits on it, so a + cycle is hard to build in the first place; the dashboard refuses to issue a + blocked package and points at the form for the logged override. + +`data.seq` (the SOP sequence phase) is still stored and shown, but it is +descriptive — it gates nothing. + +## Critical constraints reopened after release + +A constraint marked **Critical** on the SOP that reopens **after** the package was +released emails the **owner, PM, CM and everyone on the package's distribution +list** (minus whoever reopened it), and writes a `constraint_reopened` audit event. + +Detected by comparing incoming constraints against the stored ones inside the +normal upsert — *not* a separate endpoint, because the browser saves through the +sync outbox, which only replays `POST /api/wps`; anything hung off another route +would be lost offline. It fires only on a real transition (cleared/N-A → open), so +re-saving an already-open constraint doesn't re-announce, and never for a package +that was never released or a non-critical constraint. Bodies carry the constraint +name, WP number and a link — never the package contents. + ## Localization (dates, times, numbers) Three levels, most specific first — resolved in `html/wp-format.js`: diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js index 9597da8..3a650db 100644 --- a/html/wp-creation-app.js +++ b/html/wp-creation-app.js @@ -382,6 +382,114 @@ function resetPeopleForNewPackage(){ renderPeoplePicker('distribution'); } +// ── PREDECESSOR WORK PACKAGES ───────────────────────────────────────────────── +// "Package Predecessor" used to be a free-text SOP phase label, which couldn't +// express "WP04 waits on WP02" and gated nothing. It's now a set of references to +// other packages on the project, and an unclosed predecessor makes a package not +// release-ready (server: enforce_release_gates). The SOP phase survives as the +// descriptive "Sequence phase" field beside it. +let pkgPredecessors = []; // [wpId] +let pkgGateOverride = null; // {reason, at, by} once a planner releases early + +function wpById(id){ return savedPackages.find(p => p.id === id) || null; } + +// Everything this package already blocks, directly or through a chain — offering +// any of them as a predecessor would create a cycle, so they're excluded. +function descendantsOf(id){ + const out = new Set(); + const stack = [id]; + while(stack.length){ + const cur = stack.pop(); + savedPackages.forEach(p => { + if((p.predecessors || []).includes(cur) && !out.has(p.id)){ + out.add(p.id); + stack.push(p.id); + } + }); + } + return out; +} + +function predCandidates(){ + const self = editingId || ''; + const banned = self ? descendantsOf(self) : new Set(); + return savedPackages.filter(p => p.id !== self && !banned.has(p.id)); +} + +// Predecessors that aren't Closed yet. Missing ones (deleted since) don't block — +// a deleted package must not freeze everything downstream of it. +function blockingPredecessors(){ + return pkgPredecessors.map(id => wpById(id)).filter(p => p && p.status !== 'Closed'); +} + +function predLabel(p){ return (p.number || '(unnumbered)') + ' — ' + (p.subject || 'untitled'); } + +function renderPredPicker(){ + const box = document.getElementById('pick_predecessors'); + if(!box) return; + const chips = pkgPredecessors.map((id, i) => { + const p = wpById(id); + const gone = !p; + const done = p && p.status === 'Closed'; + const title = gone ? 'This package no longer exists — it does not block release.' + : (done ? 'Closed — cleared' : p.status + ' — blocks release until Closed'); + const cls = done ? ' pp-locked' : ''; + const name = gone ? '(deleted package)' : (p.number || p.subject || id); + return `${done?'✓ ':''}${esc(name)}` + + ``; + }).join(''); + const cands = predCandidates(); + const opts = cands.map(p => { + const on = pkgPredecessors.includes(p.id); + return ``; + }).join(''); + box.innerHTML = chips + + ` + + + `; + renderPredHint(); +} + +function renderPredHint(){ + const el = document.getElementById('pred-hint'); + if(!el) return; + const blocking = blockingPredecessors(); + if(!pkgPredecessors.length){ + el.textContent = 'None — this package can be released as soon as its constraints are cleared.'; + el.style.color = ''; + } else if(blocking.length){ + el.innerHTML = '⛔ Waiting on ' + blocking.map(p => esc(p.number || p.id) + ' (' + esc(p.status) + ')').join(', ') + + '. Release is gated until they are Closed.'; + el.style.color = 'var(--red)'; + } else { + el.textContent = '✓ All predecessors are Closed.'; + el.style.color = 'var(--accent-green)'; + } +} + +function togglePredecessor(id, on){ + const ix = pkgPredecessors.indexOf(id); + if(on && ix < 0) pkgPredecessors.push(id); + else if(!on && ix >= 0) pkgPredecessors.splice(ix, 1); + // The set changed, so a previously-granted override no longer describes reality. + pkgGateOverride = null; + renderPredPicker(); + updateReleaseBanner(); +} + +function removePredecessor(i){ + pkgPredecessors.splice(i, 1); + pkgGateOverride = null; + renderPredPicker(); + updateReleaseBanner(); +} + // ── SOP-inherited hints → label tooltips (site comment 8/3) ─────────────────── // The blue "from SOP types" subtext under a field becomes a small SOP chip on the // label, with the detail on hover. The hint elements stay in the DOM (hidden) so @@ -809,7 +917,13 @@ function setConstraint(i,val){ // issue it and scroll up to the status control so the change is visible. if(before==='open' && val!=='open' && readiness().open===0){ const st=getRadio('status'); - if(STATUS_ORDER.indexOf(st) < ISSUED_IDX){ + // Don't offer to issue while a predecessor is still open — that would walk the + // user straight into the override prompt they didn't ask for. + if(readiness().blocking.length){ + renderPredHint(); + toast('All constraints cleared — still waiting on '+readiness().blocking.length+' predecessor package(s).'); + } + else if(STATUS_ORDER.indexOf(st) < ISSUED_IDX){ if(confirm('All constraints are cleared — this Work Package is release-ready.\n\nMark it as Issued now?')){ setRadio('status','Issued'); prevStatus='Issued'; updateReleaseBanner(); track('status_change',{status:'Issued',via:'constraint_clear'}); @@ -819,23 +933,73 @@ function setConstraint(i,val){ } } } -function readiness(){ const open=pkgConstraints.filter(c=>c.status==='open').length; return {open, total:pkgConstraints.length, cleared:pkgConstraints.filter(c=>c.status==='cleared').length, ready:open===0}; } +// Release readiness has two gates now: every constraint cleared/N-A, and every +// predecessor package Closed. `ready` means both; `open`/`blocking` say which. +function readiness(){ + const open = pkgConstraints.filter(c=>c.status==='open').length; + const blocking = blockingPredecessors(); + return { + open, blocking, + total: pkgConstraints.length, + cleared: pkgConstraints.filter(c=>c.status==='cleared').length, + constraintsClear: open === 0, + ready: open === 0 && blocking.length === 0 + }; +} 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(r.ready){ cls='rb-ready'; txt=`✓ Release-ready — all ${r.total} constraints cleared or N/A.`; } - else { cls='rb-notready'; txt=`⚠ Not release-ready — ${r.open} of ${r.total} constraint${r.open===1?'':'s'} still open.`; } + else if(r.constraintsClear){ + // Constraints are done; what's left is upstream work. + cls='rb-notready'; + txt=`⚠ Not release-ready — waiting on ${r.blocking.map(p=>esc(p.number||p.id)+' ('+esc(p.status)+')').join(', ')}.`; + } + else { + cls='rb-notready'; + const extra = r.blocking.length ? ` · also waiting on ${r.blocking.length} predecessor${r.blocking.length===1?'':'s'}` : ''; + txt=`⚠ Not release-ready — ${r.open} of ${r.total} constraint${r.open===1?'':'s'} still open${extra}.`; + } b.innerHTML=`
${txt}
`; updateStickyStatus(); } +// Releasing with an unclosed predecessor is allowed but must be explained. The +// reason rides on the package (data.gateOverride) and the server writes it to the +// audit log. Returns false if the user backed out. +function confirmEarlyRelease(blocking){ + const list=blocking.map(p=>'• '+(p.number||p.id)+' — '+p.status).join('\n'); + const reason=prompt('These predecessor packages are not Closed yet:\n\n'+list+ + '\n\nYou can still release this package, but the reason is recorded on it and in the audit log.\n\n'+ + 'Why is it being released now? (Cancel to stop.)'); + if(reason===null || !reason.trim()) return false; + pkgGateOverride={ + reason: reason.trim(), + at: new Date().toISOString(), + by: (window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '', + blocking: blocking.map(p=>p.number||p.id) + }; + track('predecessor_gate_overridden'); + return true; +} + function onStatusChange(target){ const idx=STATUS_ORDER.indexOf(target); - if(idx>=ISSUED_IDX && readiness().open>0){ + const r=readiness(); + // Constraints are a hard gate: nothing releases with one open. + if(idx>=ISSUED_IDX && r.open>0){ const open=pkgConstraints.filter(c=>c.status==='open').map(c=>c.name); alert('Cannot move to "'+target+'" — these constraints are still open:\n\n• '+open.join('\n• ')+'\n\nClear or mark N/A first.'); setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return; } + // Predecessors are a gate you can refuse: planners genuinely need to release + // ahead of upstream work closing out. Refusing it requires a reason, which is + // stored on the package and written to the audit log by the server. + if(idx>=ISSUED_IDX && r.blocking.length && !pkgGateOverride){ + if(!confirmEarlyRelease(r.blocking)){ + setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return; + } + } if(target==='Issue'){ openHoldModal(); return; } // modal commits or reverts prevStatus=target; updateReleaseBanner(); track('status_change',{status:target}); } @@ -945,7 +1109,12 @@ function collectPackage(){ disciplines:[...pkgDisciplines], scope: isMultiDiscipline() ? Object.fromEntries(pkgDisciplines.map(d=>[d,(pkgScope[d]||[]).map(s=>s.trim()).filter(Boolean)])) : undefined, discStatus: isMultiDiscipline() ? {...pkgDiscStatus} : undefined, - hours:gv('wp_hours'), seq:gv('wp_seq'), + hours:gv('wp_hours'), + // seq is the SOP sequence phase (descriptive). predecessors are references to + // other packages and are what actually gate release. + seq:gv('wp_seq'), + predecessors:pkgPredecessors.slice(), + gateOverride:pkgGateOverride || undefined, assets:pkgAssets.filter(a=>a.tag||a.link||a.desc), materials:pkgMaterials.filter(m=>m.qty||m.desc), attachments:pkgAttach.filter(a=>a.doc), kitStatus:gv('wp_kit_status'), kitOwner:gv('wp_kit_owner'), kitDate:gv('wp_kit_date'), @@ -974,6 +1143,12 @@ function collectPackage(){ function savePackage(view){ if(!gv('wp_subject') || !gv('wp_type')){ alert('Subject and WP Type are required.'); return; } // A BIM package marked "Signed off (IFF)" without the number isn't traceable. + // The status can also be set programmatically (the per-discipline roll-up), so + // re-check the predecessor gate at the point of saving. + const _r=readiness(); + if(STATUS_ORDER.indexOf(getRadio('status'))>=ISSUED_IDX && _r.blocking.length && !pkgGateOverride){ + if(!confirmEarlyRelease(_r.blocking)) return; + } if(isEwp() && iffRequired() && !gv('wp_iff').trim()){ alert('Coordination is set to "Signed off (IFF)" — enter the IFF number so the sign-off is traceable.'); const el=document.getElementById('wp_iff'); if(el){ el.focus(); el.scrollIntoView({behavior:'smooth',block:'center'}); } @@ -1031,7 +1206,13 @@ function renderPackage(pkg){ h+=`

3.0 Scope & Work

- + + + ${pkg.gateOverride?``:''}
Description of Work${scopeHtml}
Labor Est. Hrs.${cell(pkg.hours)}
Package Predecessor${pkg.seq?('After: '+esc(pkg.seq)):'None (no predecessor)'}
Predecessor packages${ + (pkg.predecessors && pkg.predecessors.length) + ? pkg.predecessors.map(id=>{ const q=wpById(id); return q ? (esc(q.number||id)+' — '+esc(q.status)) : esc(id)+' (deleted)'; }).join('
') + : 'None' + }
Sequence phase${pkg.seq?esc(pkg.seq):ns()}
Released earlyPredecessor gate overridden.
${esc(pkg.gateOverride.reason||'')}
${esc(pkg.gateOverride.by||'')} · ${esc(pkg.gateOverride.at?wpFormatDateTime(pkg.gateOverride.at):'')}
`; if(pkg.materials&&pkg.materials.length){ const showDisc=pkg.materials.some(m=>m.discipline); h+=`

4.0 Material List

${showDisc?'':''}`; @@ -1164,7 +1345,8 @@ function updateStickyStatus(){ const r=readiness(); const st=getRadio('status'); if(st==='Issue'){ el.className='sticky-status ss-hold'; el.textContent=`⛔ On hold — ${r.open} constraint${r.open===1?'':'s'} reopened`; } else if(r.ready){ el.className='sticky-status ss-ready'; el.textContent=`✓ Release-ready — all ${r.total} constraints cleared`; } - else { el.className='sticky-status ss-notready'; el.textContent=`⚠ ${r.open} of ${r.total} constraint${r.open===1?'':'s'} open`; } + else if(r.constraintsClear){ el.className='sticky-status ss-notready'; el.textContent=`⚠ Waiting on ${r.blocking.length} predecessor${r.blocking.length===1?'':'s'}`; } + else { el.className='sticky-status ss-notready'; el.textContent=`⚠ ${r.open} of ${r.total} constraint${r.open===1?'':'s'} open`+(r.blocking.length?` · ${r.blocking.length} predecessor${r.blocking.length===1?'':'s'}`:''); } } // ── SAVED PACKAGES ─────────────────────────────────────────────────────────── @@ -1176,6 +1358,7 @@ function loadStore(){ try{ const d=JSON.parse(localStorage.getItem(wpKey(STORE_K function renderSavedList(){ const card=document.getElementById('saved-card'), body=document.getElementById('saved-body'); renderWpNav(); + renderPredPicker(); // candidates + blocking state change as packages are saved document.getElementById('saved-count').textContent=savedPackages.length?`(${savedPackages.length})`:''; if(!savedPackages.length){ card.style.display='none'; return; } if(document.getElementById('pkg-output').style.display==='none' || document.getElementById('pkg-output').style.display==='') card.style.display=''; @@ -1223,8 +1406,11 @@ function renderWpNav(){ const label = k==='Issue' ? 'Issue (Hold)' : k; return ''+groups[k].map(r=>{ const p=r.p, open=(p.constraints||[]).filter(c=>c.status==='open').length; - const dot = p.status==='Issue' ? 'hold' : (open===0 ? 'ok' : 'open'); - const state = p.status==='Issue' ? 'on hold' : (open===0 ? 'ready' : open+' open'); + // Waiting on an unclosed predecessor is 'not ready' too, not just open constraints. + const waiting=(p.predecessors||[]).map(id=>wpById(id)).filter(x=>x&&x.status!=='Closed').length; + const dot = p.status==='Issue' ? 'hold' : ((open===0 && !waiting) ? 'ok' : 'open'); + const state = p.status==='Issue' ? 'on hold' + : (open ? open+' open' : (waiting ? 'waits on '+waiting : 'ready')); const active = (editingId && p.id===editingId) ? ' active' : ''; return '`; } else { - const canIssue = !p.split && open===0 && p.status!=='Closed' && p.status!=='Issued' && p.status!=='Issue'; + const canIssue = !p.split && open===0 && !waiting.length && p.status!=='Closed' && p.status!=='Issued' && p.status!=='Issue'; const issueBtn = canIssue?` `:''; actions=`${issueBtn}`; } @@ -1582,6 +1779,15 @@ function renderDashboard(){ function dashIssue(id){ const p=WPData.get(id); if(!p) return; if(wpOpenConstraints(p).length>0){ alert('Cannot issue — open constraints remain.'); return; } + const waiting=wpWaitingOn(p); + if(waiting.length){ + // Releasing early needs a reason, same as on the form — the dashboard must not + // be the quiet way around the gate. + alert('Cannot issue from here — waiting on:\n\n• '+ + waiting.map(w=>(w.number||w.id)+' — '+w.status).join('\n• ')+ + '\n\nOpen the package to release it early with a logged reason.'); + return; + } if(!confirm('Issue work package "'+(p.number||p.subject)+'"? This marks it released to the field.')) return; WPData.issue(id); renderDashboard(); renderSavedList(); toast('Issued '+(p.number||'')); track('dashboard_issue'); } diff --git a/html/wp-creation-index.html b/html/wp-creation-index.html index a7c4558..a99f0f4 100644 --- a/html/wp-creation-index.html +++ b/html/wp-creation-index.html @@ -164,7 +164,11 @@
-
The package/step (from the SOP sequence) that must finish before this work can start. Choose "None" if it has no predecessor.
+
+
+
+
+
from the SOP construction sequence
diff --git a/server/app.py b/server/app.py index 0703a24..e51bd60 100644 --- a/server/app.py +++ b/server/app.py @@ -873,6 +873,183 @@ def delete_sop(sop_id: str, user: models.User = Depends(auth.get_current_user), return {"deleted": sop_id} +# ── Release gates (constraints + predecessors) ───────────────────────────────── +# Status ladder, mirrored in the front end (wp-creation-app.js STATUS_ORDER). +STATUS_ORDER = ["Draft", "Scheduled", "Issued", "In Progress", "Issue", "QC", "Closed"] +ISSUED_IDX = STATUS_ORDER.index("Issued") +DONE_STATUS = "Closed" + + +def _released(status: Optional[str]) -> bool: + """Has this package been released to the field (Issued or anything after)?""" + try: + return STATUS_ORDER.index(status or "") >= ISSUED_IDX + except ValueError: + return False + + +def predecessor_ids(data: Optional[dict]) -> list[str]: + """Work-package ids this package waits on. Ignores anything malformed rather + than failing a save — a bad id simply can't block.""" + raw = (data or {}).get("predecessors") or [] + if not isinstance(raw, list): + return [] + return [p for p in raw if isinstance(p, str) and _ID_RE.match(p)] + + +def gate_override(data: Optional[dict]) -> Optional[dict]: + """A deliberate, reasoned override of the predecessor gate. Planners legitimately + need to stage packages ahead of the work finishing, so the gate is refusable — + but only explicitly, and it lands in the audit log.""" + ov = (data or {}).get("gateOverride") + if isinstance(ov, dict) and str(ov.get("reason") or "").strip(): + return ov + return None + + +def blocking_predecessors(db: Session, wp_id: Optional[str], data: Optional[dict]) -> list[dict]: + """Predecessors that are not Closed yet. A predecessor that no longer exists is + NOT blocking — a deleted package must not freeze everything downstream.""" + ids = [p for p in predecessor_ids(data) if p != wp_id] + if not ids: + return [] + rows = db.scalars(select(models.WorkPackage).where(models.WorkPackage.id.in_(ids))).all() + return [ + {"id": r.id, "number": r.number, "subject": r.subject, "status": r.status} + for r in rows if r.status != DONE_STATUS + ] + + +def check_predecessor_cycle(db: Session, wp_id: str, data: Optional[dict]) -> None: + """Refuse a predecessor set that would make A wait on itself (directly or + through a chain). Walks the graph from the proposed predecessors; the visited + set also bounds the walk, so a cycle that already exists elsewhere in the data + can't spin here.""" + start = [p for p in predecessor_ids(data)] + if wp_id in start: + raise HTTPException(status_code=400, detail="A work package cannot be its own predecessor.") + seen, stack = set(), list(start) + while stack: + cur = stack.pop() + if cur in seen: + continue + seen.add(cur) + row = db.get(models.WorkPackage, cur) + if row is None: + continue + nxt = predecessor_ids(row.data) + if wp_id in nxt: + raise HTTPException( + status_code=400, + detail=f"That would create a circular dependency ({row.number or row.id} already waits on this package).", + ) + stack.extend(n for n in nxt if n not in seen) + + +def enforce_release_gates(db: Session, wp_id: Optional[str], data: Optional[dict], + new_status: str, old_status: Optional[str]) -> None: + """Refuse a move to Issued (or beyond) while a release gate is unmet. Applied to + every path that can set a status — the plain upsert included, since that's how + the browser and the offline outbox save.""" + if not _released(new_status) or _released(old_status): + return # not a release transition + constraints = (data or {}).get("constraints") or [] + open_names = [c.get("name") for c in constraints if isinstance(c, dict) and c.get("status") == "open"] + if open_names: + raise HTTPException(status_code=409, detail={ + "message": "Open constraints block release", "open": open_names, + }) + blockers = blocking_predecessors(db, wp_id, data) + if blockers and not gate_override(data): + raise HTTPException(status_code=409, detail={ + "message": "Predecessors are not closed yet", + "blocking": [f"{b['number'] or b['id']} ({b['status']})" for b in blockers], + }) + + +# ── Critical-constraint notification ─────────────────────────────────────────── +# A constraint flagged CRITICAL on the SOP, reopened after the package was +# released, is announced by email. We detect it by comparing the incoming +# constraints against the stored ones during the normal upsert rather than adding a +# separate endpoint: the browser saves through the sync outbox, which only replays +# POST /api/wps, so anything hung off another route would be lost offline. +def reopened_critical(old_data: Optional[dict], new_data: Optional[dict]) -> list[str]: + old = {} + for c in (old_data or {}).get("constraints") or []: + if isinstance(c, dict) and c.get("name"): + old[c["name"]] = c.get("status") + out = [] + for c in (new_data or {}).get("constraints") or []: + if not isinstance(c, dict) or not c.get("critical"): + continue + name = c.get("name") + if not name or c.get("status") != "open": + continue + if old.get(name) not in (None, "open"): # was cleared/na, now open + out.append(name) + return out + + +def project_sop_team(db: Session, project_id: Optional[str]) -> list[str]: + """PM + CM user ids from the project's most recent complete SOP.""" + 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 [] + proj = (sop.data or {}).get("project") or {} + return [i for i in (proj.get("pmId"), proj.get("cmId")) if i] + + +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. + 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"after it was released to the field, so the package is on hold.\n\n" + f"Constraint: {which}\n" + f"Reopened by: {by}\n\n" + f"Open the package:\n{link}\n" + ) + + +def notify_critical_reopen(db: Session, wp: "models.WorkPackage", names: list[str], + actor: "models.User") -> list["models.Notification"]: + """Owner + PM + CM + everyone on the package's distribution list, minus whoever + did it (they already know) and minus duplicates.""" + ids = [] + if wp.assignee_id: + ids.append(wp.assignee_id) + ids.extend(project_sop_team(db, wp.project_id)) + ids.extend([i for i in ((wp.data or {}).get("distributionIds") or []) if isinstance(i, str)]) + seen, out = set(), [] + link = wp_link(db, wp) + for uid in ids: + if uid in seen or uid == actor.id: + continue + seen.add(uid) + u = db.get(models.User, uid) + if not u or not u.is_active: + continue + out.append(notify.enqueue( + db, user=u, kind="wp_constraint_reopened", + subject=f"On hold: {wp.number or 'work package'} — critical constraint reopened", + body=hold_body(u, wp, names, actor, link), + link=link, wp_id=wp.id, project_id=wp.project_id, + )) + return out + + # ── Work Packages ──────────────────────────────────────────────────────────── @app.post("/api/wps") def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): @@ -886,9 +1063,15 @@ def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User = is_new = wp is None old_status = None if is_new else wp.status old_assignee = None if is_new else wp.assignee_id + old_data = None if is_new else (wp.data or {}) if wp is None: wp = models.WorkPackage(id=body.id or gen_id("wp")) db.add(wp) + # Predecessors: reject a cycle, and refuse a release while a gate is unmet. + # Both run before anything is written so a rejected save changes nothing. + 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) wp.project_id = body.project_id wp.sop_id = body.sop_id wp.parent_id = body.parent_id @@ -910,6 +1093,25 @@ def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User = _act, _detail = "updated", {"status": wp.status} log_event(db, user, _act, "wp", wp.id, project_id=wp.project_id, summary=(wp.number or wp.subject or wp.id), detail=_detail) + notifs = [] + # A reasoned override of the predecessor gate is worth its own audit line — + # "released early, and here's why" is exactly what a reviewer looks for later. + ov = gate_override(body.data) + if ov and _released(wp.status) and not _released(old_status): + blockers = blocking_predecessors(db, wp.id, body.data) + log_event(db, user, "gate_overridden", "wp", wp.id, project_id=wp.project_id, + summary=(wp.number or wp.subject or wp.id), + detail={"reason": str(ov.get("reason"))[:300], + "blocking": [b["number"] or b["id"] for b in blockers]}) + # A critical constraint reopened after release: log it and tell the people who + # need to know (owner, PM, CM, distribution). + if not is_new and _released(old_status): + reopened = reopened_critical(old_data, wp.data) + if reopened: + log_event(db, user, "constraint_reopened", "wp", wp.id, project_id=wp.project_id, + summary=(wp.number or wp.subject or wp.id), + detail={"critical": reopened, "status": wp.status}) + notifs.extend(notify_critical_reopen(db, wp, reopened, user)) # Notify a newly-assigned owner (skip self-assignment). notif = None if new_assignee and new_assignee != old_assignee and new_assignee != user.id: @@ -927,7 +1129,9 @@ def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User = db.commit() db.refresh(wp) if notif is not None: - background_tasks.add_task(notify.deliver, notif.id) + notifs.append(notif) + for n in notifs: + background_tasks.add_task(notify.deliver, n.id) return wp.to_dict() @@ -1048,16 +1252,13 @@ def delete_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db @app.post("/api/wps/{wp_id}/issue") def issue_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): - """Release a Work Package to the field. Refuses if any constraint is still - open (the AWP release gate).""" + """Release a Work Package to the field. Refuses while a release gate is unmet: + any open constraint, or a predecessor package that isn't Closed.""" 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) - constraints = (wp.data or {}).get("constraints") or [] - open_names = [c.get("name") for c in constraints if c.get("status") == "open"] - if open_names: - raise HTTPException(status_code=409, detail={"message": "Open constraints block issuance", "open": open_names}) + enforce_release_gates(db, wp.id, wp.data, "Issued", wp.status) wp.status = "Issued" wp.issued_at = models.utcnow() log_event(db, user, "issued", "wp", wp.id, project_id=wp.project_id, @@ -1074,6 +1275,8 @@ def set_wp_status(wp_id: str, body: StatusIn, user: models.User = Depends(auth.g raise HTTPException(status_code=404, detail="Work Package not found") require_project_access(db, user, wp.project_id) old_status = wp.status + # 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) wp.status = body.status if body.status == "Issued" and wp.issued_at is None: wp.issued_at = models.utcnow()
QtyUnitDescriptionDiscipline