Files
PacketStation/tests/server/test_pm_export.py
T
spencermandClaude Fable 5.1 6b6db0230f
ci / frontend (push) Successful in 43s
ci / server (push) Successful in 11m6s
ci / image (push) Successful in 38s
ci / e2e (push) Successful in 3m55s
Phase 8: Project Management, find bar, presence and motion
The Project Management screen (/pm, view only) with Overview, Projects & units, Bottlenecks & aging, Hours vs target,
Quality & issues and Find jobs, the period picker, health per unit and per project, drill down, Excel (CSV) per table
and for all, and the PESCO production status report PDF with the controlled-copy stamp; every export logged as
"report exported". The find bar on every home screen: the full search set, the dropdowns, quick flags with Mine, the
flag kinds, Board and List with sorts, filters saved per browser; every filter applied by the server on the list and
the boards, with facets. Presence in packets with role, lock and revoke drop; the motion switch with reduced motion
honoured, card glides, glow and count-up; Back restores the scroll spot; sheet placement and the iPad layout pass. The
data book compared page by page with the old build and six layout differences fixed. Tests for the PM aggregates,
search, filters, presence and exports; Playwright scenarios for the PM screen, the find bar and presence at desktop and
iPad viewports. Docs updated; PLAN.md questions 38 to 42.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-18 15:08:34 -06:00

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"