Files

335 lines
16 KiB
Python
Raw Permalink Normal View History

"""Phase 6 documents: packet documents for the data book (add, the closed kind set, the file type, the filed lock,
take out with a reason and the row kept) and Q.C. folder documents on a unit or a project (add, the closed type
set, who may add, new revisions that supersede and never delete, the folder reads and counts, the audit texts)."""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
import pytest
from fakedir import FakeDirectory
from pshelpers import needs_pg
from qchelpers import (
DOC_KINDS,
MSG_DOC_KIND,
MSG_TAKE_OUT,
QC_TYPES,
add_packet_doc,
csv_asset,
filed_packet,
folder_project,
folder_unit,
make_qc_doc,
start_itp,
take_out,
)
from shophelpers import (
IT,
Shop,
all_rows,
draft,
fresh,
last_log,
make_unit,
ok,
one,
open_shop,
refused,
run,
)
from packetstation.settings import Settings
pytestmark = needs_pg
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def numbers(shop: Shop, unit_id: int) -> tuple[str, str]:
"""(project number, unit number) of a unit."""
row = one(
shop.settings,
"SELECT p.number, p.number || u.lot FROM units u JOIN projects p ON p.id = u.project_id WHERE u.id = %s",
(unit_id,),
)
return str(row[0]), str(row[1])
def add_unit(shop: Shop, project_id: int, lot: str) -> int:
row = one(
shop.settings,
"INSERT INTO units (project_id, lot, name, status) VALUES (%s, %s, 'Unit two', 'open') RETURNING id",
(project_id, lot),
)
return int(row[0])
def doc_of(pkt: dict[str, Any], doc_id: int) -> dict[str, Any]:
return next(d for d in pkt["docs"] if int(d["id"]) == doc_id)
# ================================================================ packet documents
def test_packet_doc_add(shop: Shop) -> None:
pkt = draft(shop)
q = shop("qc.quinn")
out = ok(add_packet_doc(q, pkt["id"], "nde", "RT report W1-W4", "RPT-2210, accept", "rt.pdf"))
d = out["docs"][-1]
assert d["kind"] == "nde" and d["kind_label"] == DOC_KINDS["nde"]
assert d["title"] == "RT report W1-W4" and d["note"] == "RPT-2210, accept"
assert d["file_name"] == "rt.pdf" and d["size"] > 0 and d["removed"] is None
log = last_log(q, pkt["id"])
assert (log["action"], log["note"]) == (
"Document added",
"NDE report (RT / UT / MT / PT) · RT report W1-W4 · rt.pdf",
)
# clerk.clara adds one with no title: the title is the kind label
out = ok(add_packet_doc(shop("clerk.clara"), pkt["id"], "hydro", None, None, "hydro.pdf"))
d = out["docs"][-1]
assert d["title"] == "Hydrotest report" and d["kind_label"] == "Hydrotest report"
assert last_log(q, pkt["id"])["note"] == "Hydrotest report · Hydrotest report · hydro.pdf"
# every kind of the closed set is accepted
for kind, label in DOC_KINDS.items():
out = ok(add_packet_doc(q, pkt["id"], kind, f"Doc {kind}"))
assert out["docs"][-1]["kind_label"] == label
assert [d["kind"] for d in out["docs"]][:2] == ["nde", "hydro"] # ordered added_at, id
def test_packet_doc_refusals(shop: Shop) -> None:
pkt = draft(shop)
q = shop("qc.quinn")
refused(add_packet_doc(q, pkt["id"], "xray"), 422, "invalid_kind", MSG_DOC_KIND)
refused(add_packet_doc(q, pkt["id"], asset_id=csv_asset(q)["id"]), 422, "invalid_asset")
refused(add_packet_doc(shop("wh.wade"), pkt["id"]), 403, "forbidden")
refused(add_packet_doc(shop("sup.wes"), pkt["id"]), 403, "forbidden")
assert fresh(q, pkt["id"])["docs"] == []
# over the note limit: refused, or cut to the limit (the contract gives the limit, not the answer)
r = add_packet_doc(q, pkt["id"], note="x" * 301)
assert r.status_code == 422 or len(r.json()["docs"][-1]["note"]) <= 300, r.text
def test_packet_doc_note_limit(shop: Shop) -> None:
pkt = draft(shop)
r = add_packet_doc(shop("qc.quinn"), pkt["id"], note="x" * 300)
if r.status_code == 200:
assert len(r.json()["docs"][-1]["note"]) == 300
else:
assert r.status_code == 422, r.text
def test_packet_doc_filed(shop: Shop) -> None:
pkt = filed_packet(shop)
refused(add_packet_doc(shop("qc.quinn"), pkt["id"]), 403, "filed")
refused(add_packet_doc(shop("clerk.clara"), pkt["id"]), 403, "filed")
def test_packet_doc_take_out(shop: Shop) -> None:
pkt = draft(shop)
q = shop("qc.quinn")
out = ok(add_packet_doc(q, pkt["id"], "nde", "RT report W1-W4", "RPT-2210, accept"))
doc_id = int(out["docs"][-1]["id"])
for reason in (None, "", "ab"):
refused(take_out(q, pkt["id"], doc_id, reason), 400, "reason_required", MSG_TAKE_OUT)
refused(take_out(shop("wh.wade"), pkt["id"], doc_id), 403, "forbidden")
out = ok(take_out(shop("clerk.clara"), pkt["id"], doc_id, "wrong packet"))
d = doc_of(out, doc_id)
assert d["removed"]["reason"] == "wrong packet" and d["removed"]["at"]
assert d["removed"]["by"]["display_name"] == "Clara Clerk"
log = last_log(q, pkt["id"])
assert (log["action"], log["note"]) == ("Document taken out of the data book", "RT report W1-W4 · wrong packet")
refused(take_out(q, pkt["id"], doc_id, "again please"), 409, "already_removed")
row = one(shop.settings, "SELECT removed_reason, removed_by IS NOT NULL FROM packet_docs WHERE id = %s", (doc_id,))
assert tuple(row) == ("wrong packet", True)
# nothing is deleted
assert all_rows(shop.settings, "SELECT count(*) FROM packet_docs WHERE packet_id = %s", (pkt["id"],))[0][0] == 1
def test_qc_folder_docs_on_the_packet(shop: Shop) -> None:
project_id, unit_id = make_unit(shop.settings)
pkt = draft(shop, unit_id)
assert fresh(shop("qc.quinn"), pkt["id"])["qc_folder_docs"] == []
ok(make_qc_doc(shop, unit_id=unit_id, type="ITP", title="ITP unit one"))
ok(make_qc_doc(shop, project_id=project_id, type="WPS / PQR", title="WPS-101 GTAW/SMAW", rev="2"))
docs = fresh(shop("wh.wade"), pkt["id"])["qc_folder_docs"]
by_scope = {d["scope"]: d for d in docs}
assert set(by_scope) == {"unit", "project"}
assert by_scope["unit"]["type"] == "ITP" and by_scope["unit"]["title"] == "ITP unit one"
assert by_scope["project"]["rev"] == "2" and by_scope["project"]["url"].startswith("/_blob/")
# ================================================================ Q.C. folder documents
def test_qc_doc_add_unit_and_project(shop: Shop) -> None:
project_id, unit_id = make_unit(shop.settings)
pno, uno = numbers(shop, unit_id)
out = ok(make_qc_doc(shop, unit_id=unit_id, type="ITP", title="ITP unit one", file_name="itp.pdf"))
assert out["unit"]["id"] == unit_id and out["project"]["id"] == project_id
d = out["docs"][0]
assert d["type"] == "ITP" and d["unit_id"] == unit_id and d["project_id"] is None
assert d["file_name"] == "itp.pdf" and d["url"] == f"/_blob/{d['asset_id']}" and d["auto"] is False
assert d["added_by"]["display_name"] == "Quinn QC" and d["superseded_by"] is None
out = ok(
make_qc_doc(
shop, project_id=project_id, type="WPS / PQR", title="WPS-101 GTAW/SMAW", rev="2", file_name="wps.pdf"
)
)
assert out["project"]["id"] == project_id and "units" in out
assert [x["title"] for x in out["docs"]] == ["WPS-101 GTAW/SMAW"]
# the audit rows and their texts
rows = all_rows(
shop.settings, "SELECT action, subject_type, detail FROM audit_events WHERE action LIKE 'qc_doc.%%' ORDER BY id"
)
assert [r[0] for r in rows] == ["qc_doc.add", "qc_doc.add"]
assert rows[0][2]["text"] == "Q.C. document added"
assert rows[0][2]["note"] == f"{uno} · ITP · ITP unit one · itp.pdf"
assert rows[1][2]["note"] == f"{pno} · WPS / PQR · WPS-101 GTAW/SMAW Rev 2 · wps.pdf"
# the title defaults to the type
out = ok(make_qc_doc(shop, unit_id=unit_id, type="NDE procedure"))
assert out["docs"][-1]["title"] == "NDE procedure"
for t in QC_TYPES:
assert ok(make_qc_doc(shop, unit_id=unit_id, type=t))["docs"][-1]["type"] == t
def test_qc_doc_refusals(shop: Shop) -> None:
project_id, unit_id = make_unit(shop.settings)
refused(make_qc_doc(shop, unit_id=unit_id, project_id=project_id), 422, "invalid_request")
refused(make_qc_doc(shop), 422, "invalid_request")
refused(
make_qc_doc(shop, unit_id=unit_id, type="Drawing"), 422, "invalid_type", "Pick a document type from the list."
)
refused(make_qc_doc(shop, unit_id=unit_id, asset_id=csv_asset(shop("qc.quinn"))["id"]), 422, "invalid_asset")
refused(make_qc_doc(shop, unit_id=unit_id, by="wh.wade"), 403, "forbidden")
refused(make_qc_doc(shop, unit_id=unit_id, by="weld.andy"), 403, "forbidden")
refused(make_qc_doc(shop, unit_id=unit_id, by="pm.paula"), 403, "forbidden")
refused(make_qc_doc(shop, unit_id=unit_id, by="admin.ann"), 400, "it_reason_required")
assert folder_unit(shop("qc.quinn"), unit_id)["docs"] == []
# who may add: Clerical, Q.C., and an admin with an IT reason
ok(make_qc_doc(shop, unit_id=unit_id, by="clerk.clara"))
ok(make_qc_doc(shop, unit_id=unit_id, by="multi.mia"))
out = ok(make_qc_doc(shop, unit_id=unit_id, by="admin.ann", it_reason=IT))
assert len(out["docs"]) == 3
row = one(shop.settings, "SELECT it_reason FROM audit_events WHERE action = 'qc_doc.add' ORDER BY id DESC LIMIT 1")
assert row[0] == IT
# over the limits (title 140, rev 12): refused, or cut to the limit
for field, n in (("title", 140), ("rev", 12)):
r = make_qc_doc(shop, unit_id=unit_id, **{field: "x" * (n + 1)})
assert r.status_code == 422 or len(r.json()["docs"][-1][field]) <= n, (field, r.text)
def test_qc_doc_new_revision(shop: Shop) -> None:
project_id, unit_id = make_unit(shop.settings)
other_unit = add_unit(shop, project_id, "002")
uno = numbers(shop, unit_id)[1]
first = ok(make_qc_doc(shop, unit_id=unit_id, type="ITP", title="ITP L1", rev="0"))["docs"][0]
# a revision from another folder or of a missing row is refused
refused(make_qc_doc(shop, unit_id=other_unit, replaces=first["id"]), 422, "invalid_replace")
refused(make_qc_doc(shop, project_id=project_id, replaces=first["id"]), 422, "invalid_replace")
refused(make_qc_doc(shop, unit_id=unit_id, replaces=999999), 422, "invalid_replace")
out = ok(
make_qc_doc(
shop, unit_id=unit_id, type="ITP", title="ITP L1", rev="1", replaces=first["id"], file_name="itp1.pdf"
)
)
assert [d["rev"] for d in out["docs"]] == ["1"]
assert [d["id"] for d in out["superseded"]] == [first["id"]]
old = out["superseded"][0]
new_id = out["docs"][0]["id"]
assert old["superseded_by"] == new_id and old["superseded_at"]
assert old["superseded_by_user"]["display_name"] == "Quinn QC"
# a second revision of the same old row
refused(make_qc_doc(shop, unit_id=unit_id, rev="1b", replaces=first["id"]), 409, "already_superseded")
# the new one can be revised again; superseded is newest first
out = ok(make_qc_doc(shop, unit_id=unit_id, type="ITP", title="ITP L1", rev="2", replaces=new_id, by="clerk.clara"))
assert [d["rev"] for d in out["docs"]] == ["2"]
assert [d["rev"] for d in out["superseded"]] == ["1", "0"]
# nothing is ever deleted
assert all_rows(shop.settings, "SELECT count(*) FROM qc_docs WHERE unit_id = %s", (unit_id,))[0][0] == 3
rows = all_rows(shop.settings, "SELECT action, detail FROM audit_events WHERE action LIKE 'qc_doc.%%' ORDER BY id")
assert [r[0] for r in rows] == ["qc_doc.add", "qc_doc.revise", "qc_doc.revise"]
assert rows[1][1]["text"] == "Q.C. document revised"
assert rows[1][1]["note"] == f"{uno} · ITP · ITP L1 Rev 1 · itp1.pdf"
def test_qc_folders_and_counts(shop: Shop) -> None:
q = shop("qc.quinn")
project_id, unit_1 = make_unit(shop.settings)
unit_2 = add_unit(shop, project_id, "002")
closed_id, _ = make_unit(shop.settings)
run(shop.settings, "UPDATE projects SET status = 'closed' WHERE id = %s", (closed_id,))
def row(show: str, pid: int) -> dict[str, Any] | None:
rows = ok(q.get(f"/api/qc/folders?show={show}"))
return next((r for r in rows if r["id"] == pid), None)
r = row("active", project_id)
assert r is not None and r["units"] == 2 and r["docs"] == 0 and r["units_without_itp"] == 2
assert {"number", "name", "customer", "po", "status"} <= set(r)
assert row("active", closed_id) is None
closed = row("all", closed_id)
assert closed is not None and closed["units_without_itp"] == 0 and closed["status"] == "closed"
# an ITP document on unit 1, then a digital ITP on unit 2
ok(make_qc_doc(shop, unit_id=unit_1, type="ITP"))
r = row("active", project_id)
assert r is not None and r["units_without_itp"] == 1 and r["docs"] == 1
start_itp(shop, unit_2)
r = row("active", project_id)
assert r is not None and r["units_without_itp"] == 0
# a project document counts too; a WPS is not an ITP
ok(make_qc_doc(shop, project_id=project_id, type="WPS / PQR"))
r = row("active", project_id)
assert r is not None and r["docs"] == 2 and r["units_without_itp"] == 0
# the home lists the active folders
home = ok(q.get("/api/qc/home"))
assert any(f["id"] == project_id for f in home["folders"])
assert not any(f["id"] == closed_id for f in home["folders"])
numbers_in_order = [f["number"] for f in ok(q.get("/api/qc/folders?show=all"))]
assert numbers_in_order == sorted(numbers_in_order)
def test_project_itp_document_covers_every_unit(shop: Shop) -> None:
project_id, unit_1 = make_unit(shop.settings)
add_unit(shop, project_id, "002")
ok(make_qc_doc(shop, project_id=project_id, type="ITP", title="Project ITP"))
rows = ok(shop("qc.quinn").get("/api/qc/folders?show=active"))
r = next(x for x in rows if x["id"] == project_id)
assert r["units_without_itp"] == 0
assert folder_unit(shop("qc.quinn"), unit_1)["no_itp"] is False
def test_project_and_unit_folder_reads(shop: Shop) -> None:
q = shop("qc.quinn")
project_id, unit_1 = make_unit(shop.settings)
unit_2 = add_unit(shop, project_id, "002")
pno, uno = numbers(shop, unit_1)
ok(make_qc_doc(shop, project_id=project_id, type="WPS / PQR", title="WPS-101", rev="2"))
ok(make_qc_doc(shop, unit_id=unit_1, type="Customer spec", title="Spec A"))
itp = start_itp(shop, unit_2)
pf = folder_project(q, project_id)
assert pf["project"]["number"] == pno and pf["project"]["customer"] == "Acme Refining"
assert [d["title"] for d in pf["docs"]] == ["WPS-101"] and pf["superseded"] == []
units = {u["id"]: u for u in pf["units"]}
assert set(units) == {unit_1, unit_2}
assert units[unit_1]["number"] == uno and units[unit_1]["docs"] == 1 and units[unit_1]["itp"] is None
assert units[unit_1]["has_itp_doc"] is False
assert units[unit_2]["itp"]["id"] == itp["id"] and units[unit_2]["itp"]["no"] == itp["no"]
assert units[unit_2]["itp"]["total"] == 70 and units[unit_2]["itp"]["done"] == 0
uf = folder_unit(q, unit_1)
assert uf["unit"]["number"] == uno and uf["project"]["id"] == project_id
assert [d["title"] for d in uf["docs"]] == ["Spec A"]
assert [d["title"] for d in uf["inherited"]] == ["WPS-101"]
assert uf["itp"] is None and uf["no_itp"] is True
uf2 = folder_unit(q, unit_2)
assert uf2["itp"]["status"] == "open" and uf2["no_itp"] is False
# any session reads; unknown ids are 404
assert shop("wh.wade").get(f"/api/qc/folders/units/{unit_1}").status_code == 200
assert q.get("/api/qc/folders/units/999999").status_code == 404
assert q.get("/api/qc/folders/projects/999999").status_code == 404