232 lines
8.8 KiB
Python
232 lines
8.8 KiB
Python
"""Phase 8: the Project Management exports. The CSV per table and for every table (the header rows, the formula
|
|||
|
|
guard, the BOM and CRLF), the production status report PDF (pages, cover, the controlled copy stamp on every page),
|
||
|
|
and one print_log row plus one audit row `report exported` for each export.
|
||
|
|
|
||
|
|
The PM world (pmhelpers.py) is built once for the module; one draft's title starts with `=`.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import csv
|
||
|
|
import io
|
||
|
|
import re
|
||
|
|
from collections.abc import Iterator
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from closehelpers import flat, page_texts, pdf_pages, q1, qall
|
||
|
|
from pmhelpers import (
|
||
|
|
FORMULA_TITLE,
|
||
|
|
HEADS,
|
||
|
|
PERIODS,
|
||
|
|
PmWorld,
|
||
|
|
anon,
|
||
|
|
module_shop,
|
||
|
|
pm_world,
|
||
|
|
titles,
|
||
|
|
)
|
||
|
|
from pshelpers import needs_pg
|
||
|
|
from shophelpers import run
|
||
|
|
|
||
|
|
pytestmark = needs_pg
|
||
|
|
|
||
|
|
TABLE_KEYS = tuple(HEADS) # projects, aging_*, hours_*, quality_*, jobs
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(scope="module")
|
||
|
|
def world(tmp_path_factory: pytest.TempPathFactory) -> Iterator[PmWorld]:
|
||
|
|
with module_shop(tmp_path_factory.mktemp("pm_export")) as shop:
|
||
|
|
w = pm_world(shop)
|
||
|
|
run(shop.settings, "UPDATE packets SET title = %s WHERE id = %s", (FORMULA_TITLE, w.k["k_draft"]))
|
||
|
|
yield w
|
||
|
|
|
||
|
|
|
||
|
|
def export(w: PmWorld, table: str, sam: str = "pm.paula", **params: Any) -> Any:
|
||
|
|
return w.shop(sam).get("/api/pm/export.csv", params={"table": table, **params})
|
||
|
|
|
||
|
|
|
||
|
|
def parse(body: bytes) -> list[list[str]]:
|
||
|
|
assert body.startswith(b"\xef\xbb\xbf"), body[:10] # the BOM, for Excel
|
||
|
|
assert b"\r\n" in body and body.count(b"\n") == body.count(b"\r\n"), "every line ends CRLF"
|
||
|
|
return list(csv.reader(io.StringIO(body.decode("utf-8-sig"), newline="")))
|
||
|
|
|
||
|
|
|
||
|
|
def filename(r: Any) -> str:
|
||
|
|
cd = r.headers["content-disposition"]
|
||
|
|
m = re.search(r'filename\*?=(?:UTF-8\'\')?"?([^";]+)"?', cd)
|
||
|
|
assert m, cd
|
||
|
|
return m.group(1)
|
||
|
|
|
||
|
|
|
||
|
|
def print_rows(w: PmWorld, kind: str) -> list[Any]:
|
||
|
|
return qall(
|
||
|
|
w.settings,
|
||
|
|
"SELECT what, pages, by_name, packet_id, unit_id FROM print_log WHERE kind = %s ORDER BY id",
|
||
|
|
(kind,),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def audit_rows(w: PmWorld) -> list[dict[str, Any]]:
|
||
|
|
return [
|
||
|
|
r[0] for r in qall(w.settings, "SELECT detail FROM audit_events WHERE action = 'report exported' ORDER BY id")
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
def header_ok(rows: list[list[str]], name: str, label: str = "All projects", period: str = "90") -> None:
|
||
|
|
assert rows[0] == [f"PESCO Packet Station — {name}"], rows[0]
|
||
|
|
assert rows[1] == ["Scope", label]
|
||
|
|
assert rows[2] == ["History period", PERIODS[period]]
|
||
|
|
assert rows[3][0] == "Exported" and rows[3][2] == "by" and rows[3][3] == "Paula PM" and rows[3][1]
|
||
|
|
assert rows[4] == [] or rows[4] == [""]
|
||
|
|
|
||
|
|
|
||
|
|
def table_at(rows: list[list[str]], title: str) -> tuple[list[str], list[list[str]]]:
|
||
|
|
"""The head and the body rows of the table titled `title` in a parsed CSV."""
|
||
|
|
i = rows.index([title])
|
||
|
|
head = rows[i + 1]
|
||
|
|
body = []
|
||
|
|
for r in rows[i + 2 :]:
|
||
|
|
if r in ([], [""]):
|
||
|
|
break
|
||
|
|
body.append(r)
|
||
|
|
return head, body
|
||
|
|
|
||
|
|
|
||
|
|
# ================================================================ CSV
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("key", TABLE_KEYS)
|
||
|
|
def test_csv_per_table(world: PmWorld, key: str) -> None:
|
||
|
|
r = export(world, key)
|
||
|
|
assert r.status_code == 200, r.text
|
||
|
|
assert r.headers["content-type"].startswith("text/csv")
|
||
|
|
assert "attachment" in r.headers["content-disposition"]
|
||
|
|
name = filename(r)
|
||
|
|
assert name.startswith("PacketStation_") and name.endswith(f"_{world.today.isoformat()}.csv"), name
|
||
|
|
assert re.fullmatch(r"PacketStation_[A-Za-z0-9_]+_\d{4}-\d{2}-\d{2}\.csv", name), name
|
||
|
|
rows = parse(r.content)
|
||
|
|
title = titles()[key]
|
||
|
|
assert rows[0][0].startswith("PESCO Packet Station — ")
|
||
|
|
head, body = table_at(rows, title)
|
||
|
|
assert head == HEADS[key]
|
||
|
|
assert all(len(b) == len(head) for b in body), key
|
||
|
|
|
||
|
|
|
||
|
|
def test_csv_jobs_and_the_formula_guard(world: PmWorld) -> None:
|
||
|
|
r = export(world, "jobs")
|
||
|
|
assert r.status_code == 200, r.text
|
||
|
|
assert filename(r) == f"PacketStation_Jobs_{world.today.isoformat()}.csv"
|
||
|
|
rows = parse(r.content)
|
||
|
|
header_ok(rows, "Jobs")
|
||
|
|
head, body = table_at(rows, titles()["jobs"])
|
||
|
|
assert head == HEADS["jobs"]
|
||
|
|
assert len(body) == 14 # 12 open and 2 filed
|
||
|
|
col = head.index("Title")
|
||
|
|
assert "'" + FORMULA_TITLE in [b[col] for b in body]
|
||
|
|
assert FORMULA_TITLE not in [b[col] for b in body]
|
||
|
|
every = [c for b in rows for c in b]
|
||
|
|
assert not any(c[:1] in ("=", "+", "-", "@", "\t") for c in every if c), "the formula guard on every cell"
|
||
|
|
|
||
|
|
|
||
|
|
def test_csv_all(world: PmWorld) -> None:
|
||
|
|
r = export(world, "all")
|
||
|
|
assert r.status_code == 200, r.text
|
||
|
|
assert filename(r) == f"PacketStation_All_reports_{world.today.isoformat()}.csv"
|
||
|
|
rows = parse(r.content)
|
||
|
|
header_ok(rows, "All reports")
|
||
|
|
for key in TABLE_KEYS:
|
||
|
|
head, _ = table_at(rows, titles()[key])
|
||
|
|
assert head == HEADS[key], key
|
||
|
|
_, units = table_at(rows, titles()["projects"])
|
||
|
|
assert len(units) >= 2 + 8 # every project row and every unit (expand all)
|
||
|
|
|
||
|
|
|
||
|
|
def test_csv_follows_scope_and_period(world: PmWorld) -> None:
|
||
|
|
pa = world.projects["L670"]
|
||
|
|
r = export(world, "quality_sentback", project_id=pa, period="365")
|
||
|
|
assert r.status_code == 200, r.text
|
||
|
|
rows = parse(r.content)
|
||
|
|
assert rows[1] == ["Scope", "Project L670"]
|
||
|
|
assert rows[2] == ["History period", PERIODS["365"]]
|
||
|
|
_, body = table_at(rows, titles("365")["quality_sentback"])
|
||
|
|
assert len(body) == 4
|
||
|
|
|
||
|
|
|
||
|
|
def test_csv_unknown_table(world: PmWorld) -> None:
|
||
|
|
r = export(world, "salaries")
|
||
|
|
assert r.status_code == 404 and r.json()["code"] == "not_found", r.text
|
||
|
|
|
||
|
|
|
||
|
|
def test_csv_is_logged(world: PmWorld) -> None:
|
||
|
|
before_print = len(print_rows(world, "report-csv"))
|
||
|
|
before_audit = len(audit_rows(world))
|
||
|
|
assert export(world, "jobs").status_code == 200
|
||
|
|
rows = print_rows(world, "report-csv")
|
||
|
|
assert len(rows) == before_print + 1
|
||
|
|
what, pages, by_name, packet_id, unit_id = rows[-1]
|
||
|
|
assert what.endswith(" · CSV · All projects") and by_name == "Paula PM"
|
||
|
|
assert packet_id is None and unit_id is None and pages is None
|
||
|
|
audit = audit_rows(world)
|
||
|
|
assert len(audit) == before_audit + 1
|
||
|
|
detail = audit[-1]
|
||
|
|
assert set(detail) >= {"name", "format", "scope", "period"}
|
||
|
|
assert str(detail["format"]).lower() == "csv" and detail["scope"] == "All projects"
|
||
|
|
assert detail["period"] in ("90", PERIODS["90"])
|
||
|
|
|
||
|
|
|
||
|
|
def test_export_needs_a_session(world: PmWorld) -> None:
|
||
|
|
c = anon(world.shop)
|
||
|
|
assert c.get("/api/pm/export.csv", params={"table": "all"}).status_code == 401
|
||
|
|
assert c.get("/api/pm/report.pdf").status_code == 401
|
||
|
|
assert world.shop("clerk.clara").get("/api/pm/export.csv", params={"table": "jobs"}).status_code == 200
|
||
|
|
|
||
|
|
|
||
|
|
# ================================================================ PDF
|
||
|
|
|
||
|
|
|
||
|
|
def test_report_pdf(world: PmWorld) -> None:
|
||
|
|
before = len(print_rows(world, "report-pdf"))
|
||
|
|
before_audit = len(audit_rows(world))
|
||
|
|
r = world.shop("pm.paula").get("/api/pm/report.pdf")
|
||
|
|
assert r.status_code == 200, r.text
|
||
|
|
assert r.headers["content-type"].startswith("application/pdf")
|
||
|
|
assert r.headers["content-disposition"].startswith("inline")
|
||
|
|
assert filename(r) == f"PESCO_Production_Status_{world.today.isoformat()}.pdf"
|
||
|
|
data = r.content
|
||
|
|
assert data[:5] == b"%PDF-"
|
||
|
|
pages = pdf_pages(data)
|
||
|
|
assert pages > 5
|
||
|
|
texts = [flat(t) for t in page_texts(data)]
|
||
|
|
assert "PRODUCTION STATUS REPORT" in texts[0]
|
||
|
|
assert "Packet Station · All projects" in texts[0]
|
||
|
|
for i, t in enumerate(texts):
|
||
|
|
assert "Controlled copy" in t, f"page {i + 1} has no stamp"
|
||
|
|
body = texts[1:]
|
||
|
|
for i, t in enumerate(body):
|
||
|
|
assert "Production status report" in t, i
|
||
|
|
assert "CONFIDENTIAL & PROPRIETARY" in t, i
|
||
|
|
every = " ".join(texts)
|
||
|
|
for part in ("SUMMARY", "PROJECTS", "FLOW", "LABOR", "ISSUES"):
|
||
|
|
assert part in every, part
|
||
|
|
assert "L670" in every
|
||
|
|
# logged once with the page count
|
||
|
|
rows = print_rows(world, "report-pdf")
|
||
|
|
assert len(rows) == before + 1
|
||
|
|
what, logged_pages, by_name, packet_id, _ = rows[-1]
|
||
|
|
assert what == "Production status report · PDF · All projects"
|
||
|
|
assert logged_pages == pages and by_name == "Paula PM" and packet_id is None
|
||
|
|
audit = audit_rows(world)
|
||
|
|
assert len(audit) == before_audit + 1 and str(audit[-1]["format"]).lower() == "pdf"
|
||
|
|
|
||
|
|
|
||
|
|
def test_report_pdf_download_and_scope(world: PmWorld) -> None:
|
||
|
|
r = world.shop("pm.paula").get(
|
||
|
|
"/api/pm/report.pdf", params={"download": 1, "project_id": world.projects["L670"], "period": "all"}
|
||
|
|
)
|
||
|
|
assert r.status_code == 200, r.text
|
||
|
|
assert r.headers["content-disposition"].startswith("attachment")
|
||
|
|
first = flat(page_texts(r.content)[0])
|
||
|
|
assert "Packet Station · Project L670" in first
|
||
|
|
row = q1(world.settings, "SELECT what FROM print_log WHERE kind = 'report-pdf' ORDER BY id DESC LIMIT 1")
|
||
|
|
assert row[0] == "Production status report · PDF · Project L670"
|