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]>
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user