${esc(p.name||'Untitled')} ${sample}
+ bar.innerHTML=`
${esc(p.name||'Untitled')} ${sample}${archived}
${esc(p.number||'')}${p.division?' · '+esc(p.division):''}
${enabledTypes().length} typesformat ${esc(g.woFormat||'—')}track: ${esc((SOP.field&&SOP.field.trackPlatform)||'—')}
`;
}
@@ -1764,6 +1769,10 @@ function collectPackage(){
};
}
async function savePackage(view){
+ if(window._projArchived){
+ toast('This project is archived — read-only. Nothing can be saved to it.', 'alert');
+ return;
+ }
if(!wpValidateForm()) 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
@@ -3833,6 +3842,15 @@ function bootData(){
loadLocations(); // CR-004: the option lists come from the server
wpFilesRefreshUsage(); // CR-007: the storage meter is live before the first upload
mreqLoadMaterials(); // CR-013/D6: the request datalist comes from the project list
+ // D7: the read-only courtesy needs the SERVER's answer, not a stale local
+ // summary - the page's project comes from the URL, which may not be the one
+ // last stored. The chip and the save guard both read this flag.
+ if(activeProjectId && typeof ProjectData!=='undefined' && ProjectData.get){
+ ProjectData.get(activeProjectId).then(p=>{
+ window._projArchived = !!(p && p.archived);
+ if(window._projArchived) renderCtxBar();
+ }).catch(()=>{});
+ }
initWpNavDrawer();
renderSavedList();
positionSectionNav();
diff --git a/server/app.py b/server/app.py
index 1b02ed5..34cc6df 100644
--- a/server/app.py
+++ b/server/app.py
@@ -1294,6 +1294,15 @@ def list_projects(
elif archived != "all":
stmt = stmt.where(models.Project.archived_at.is_(None)) # default: hide archived
rows = db.scalars(stmt.order_by(models.Project.updated_at.desc())).all()
+ # D7 / T9.8: archived projects are readable by PROJECT ADMINS only - anyone
+ # below that sees them nowhere, counts and pickers included. The default
+ # listing already excludes them; asking for them is what gets gated, and it
+ # is gated per project, so admin-on-Job-A does not surface archived Job B.
+ if archived != "exclude":
+ rows = [p for p in rows
+ if p.archived_at is None
+ or effective_role(db, user, p.id) in (
+ auth.ROLE_ADMIN, auth.ROLE_PROJECT_SUPER, auth.ROLE_PROJECT_ADMIN)]
return [p.summary() for p in rows]
diff --git a/tests/archived_check.py b/tests/archived_check.py
new file mode 100644
index 0000000..f5c9fee
--- /dev/null
+++ b/tests/archived_check.py
@@ -0,0 +1,181 @@
+#!/usr/bin/env python3
+"""Can a project admin get back into an archived project — and only they? — D7, T9.8.
+
+Archiving read as deletion because there was no way back in. Now: a separate,
+labelled, read-only list on the launcher for project admins; the server filters
+the answer by per-project role, refuses every write regardless of what the
+browser sends, and shows archived projects to nobody else anywhere - counts and
+pickers included.
+
+Exit 0 all passed, 1 a failure, 2 could not run.
+"""
+import json
+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 sections_check import set_sop # noqa: E402
+from stepper_check import dismiss_dialogs # noqa: E402
+from qa_gate_check import api # noqa: E402
+
+
+def ascii_(v, n=280):
+ return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
+
+
+def settle(seconds=0.5):
+ time.sleep(seconds)
+
+
+def archive_projB(db_path):
+ from server.db import SessionLocal
+ from server import models
+ with SessionLocal() as db:
+ proj = db.get(models.Project, "projB")
+ proj.archived_at = models.utcnow()
+ db.commit()
+
+
+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-arch-")
+ db_path = os.path.join(tmpdir, "check.db")
+ server = None
+ browser = None
+ try:
+ tok = seed(db_path)
+ set_sop(db_path, {})
+ archive_projB(db_path)
+ port = cdp.free_port()
+ base = "http://127.0.0.1:%d" % port
+ server = start_server(port, db_path)
+ root, bob, pat = tok["root"], tok["bob"], tok["pat"]
+
+ # ── 1. who sees what ──────────────────────────────────────────────────
+ print("\n1. visibility, by role")
+ _, rows = api(base, "/api/projects", root)
+ chk("the default list hides archived projects from EVERYONE, admin included",
+ all(p["id"] != "projB" for p in rows), ascii_([p["id"] for p in rows]))
+ _, rows = api(base, "/api/projects?archived=only", root)
+ chk("an admin asking for the archived list gets it",
+ [p["id"] for p in rows] == ["projB"], ascii_(rows))
+ _, rows = api(base, "/api/projects?archived=only", bob)
+ chk("a plain project user ON that project gets an empty list - no leak",
+ rows == [], ascii_(rows))
+ _, rows = api(base, "/api/projects?archived=all", bob)
+ chk("...and cannot smuggle it through archived=all either",
+ all(p["id"] != "projB" for p in rows), ascii_(rows))
+ _, rows = api(base, "/api/projects?archived=only", pat)
+ chk("a user with no access to it sees nothing, same as before",
+ rows == [], ascii_(rows))
+
+ # ── 2. the server refuses writes regardless of the browser ───────────
+ print("\n2. frozen means frozen")
+ code, out = api(base, "/api/wps", root, "POST", {
+ "id": "wpArch1", "project_id": "projB", "number": "AR-1",
+ "subject": "write into the archive", "status": "Draft",
+ "data": {"constraints": []}})
+ chk("a direct write to an archived project is refused, even for an admin",
+ code in (403, 409) and "archived" in str(out).lower(), ascii_((code, out)))
+ code, _ = api(base, "/api/projects/projB/materials", root, "POST",
+ {"description": "Sample sneak", "unit": "EA"})
+ chk("...and so is every other write route (material list)", code in (403, 409), code)
+ code, wps = api(base, "/api/wps?project_id=projB", root)
+ chk("reading it still works - archived is readable, not gone",
+ code == 200, code)
+
+ # ── 3. the launcher, both roles, at 390px ─────────────────────────────
+ print("\n3. the launcher")
+ browser = cdp.Browser(exe)
+ page = browser.page()
+ page.clear_cookies()
+ page.set_cookie("wp_session", root)
+ page.viewport(390, 844, mobile=True)
+ page.goto(base + "/index.html")
+ dismiss_dialogs(page)
+ settle(2.5)
+ sec = json.loads(page.eval("""JSON.stringify((() => {
+ const s = document.getElementById('archived-projects');
+ return {hidden: !s || s.hidden,
+ text: s ? s.textContent : '',
+ buttons: s ? s.querySelectorAll('button').length : 0};
+ })())"""))
+ chk("a project admin sees the archived list, separate and labelled",
+ not sec["hidden"] and "Archived projects" in sec["text"]
+ and "read-only" in sec["text"].lower() and sec["buttons"] == 1, ascii_(sec))
+ chk("...and it fits at 390px", page.eval(
+ "document.getElementById('archived-projects').scrollWidth <= 392"))
+
+ page.eval("document.querySelector('[data-open-archived]').click()")
+ settle(2.0)
+ chk("opening one makes it the active project",
+ page.eval("(ProjectData.getActive()||{}).id") == "projB")
+
+ # the creator's read-only courtesy on top of the server's rule
+ page.goto(base + "/wp-creation-index.html?project=projB")
+ dismiss_dialogs(page)
+ settle(2.5)
+ page.eval("window.alert=()=>{}; window.confirm=()=>false; window.prompt=()=>null;")
+ chk("the creator says ARCHIVED where the project is named",
+ "ARCHIVED" in page.eval(
+ "(document.getElementById('ctx-bar')||{textContent:''}).textContent"))
+ page.eval("document.getElementById('wp_subject').value='x'")
+ page.eval("document.getElementById('wp_type').value='Conduit Install'")
+ n0 = page.eval("savedPackages.length")
+ page.eval("void savePackage(false)")
+ settle(0.8)
+ chk("saving is refused with a reason, before the round trip",
+ page.eval("savedPackages.length") == n0
+ and "archived" in page.eval(
+ "(document.getElementById('toast')||{textContent:''}).textContent").lower())
+
+ # a NON-admin's launcher shows no archived section at all
+ page.clear_cookies()
+ page.set_cookie("wp_session", bob)
+ page.goto(base + "/index.html")
+ dismiss_dialogs(page)
+ settle(2.5)
+ chk("a non-admin's launcher never shows the section",
+ page.eval("(() => { const s=document.getElementById('archived-projects');"
+ " return !s || s.hidden; })()"))
+
+ # projB has no SOP, and GET /api/sops/latest answering 404 for it is the
+ # correct answer, not an error - the seed fixture documents exactly this
+ # false alarm.
+ js_errors = [e for e in page.js_errors()
+ if "beforeunload" not in e and "sops/latest" not in e]
+ chk("no JavaScript errors anywhere in this run", not js_errors,
+ ascii_(js_errors[:2]))
+
+ finally:
+ if browser is not None:
+ try:
+ browser.close()
+ except Exception:
+ pass
+ if server is not None:
+ try:
+ server.terminate()
+ except Exception:
+ pass
+
+ print("\n" + "-" * 54)
+ print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
+ for f in _FAIL:
+ print(" - " + f)
+ return 1 if _FAIL else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())