- C1 (issue 1): over target follows VISUAL ticket hours, not the timer. - C2 (issue 24): a Q.C. reject always needs a reason; accept needs an NCR number after a reject without one. - C3 (issue 25): an incomplete unit ITP blocks filing. - C4 (issue 27): prints open the print dialog on the page, no new tab. - C5 (issue 31): filing is blocked while VISUAL shows a work order open; the feed sets vclosed on VISUAL packets. - C6 (issue 8): the data book leaves out the history pages. - C7 (issue 18): the supervisor approves each received short; accept and filing wait for it. - C8 (issue 23): per-department checklists, edited by the department supervisor or Admin, frozen at START. - C9 (issue 32): Q.C. and Clerical may run the stamp check. - C10 (issue 33): the PM screen and reports are for PS-ProjectMgmt and Admin only. - C11 (issue 36): the Stall screen lists the packets assigned to the welder, at the supervisor's stall. Migration 0011. Docs: PLAN (also issue 9 confirmed, issue 4 asked), POLICY, SECURITY, SCHEMA, FEED, README, CHANGELOG. Co-Authored-By: Claude Opus 5.5 <[email protected]>
239 lines
8.4 KiB
Python
239 lines
8.4 KiB
Python
"""Project Management: the read-only reports, the CSV exports and the production status PDF.
|
|
|
|
PS-ProjectMgmt and Admin only (change C10, Gitea issue 33); every other role gets 403 on every endpoint here. No other
|
|
screen calls /api/pm: the find bar and the boards read /api/packets. Nothing here writes a packet; an export
|
|
writes one print_log row and one audit row (`report exported`). Every endpoint takes the find bar's scope: the
|
|
project and unit pick, dept, status, station_id, welder_id, flags, flag, q, and the history period (30, 90, 365 or
|
|
all days; default 90). Mine is ignored here. The reports read PostgreSQL only.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Depends, Query, Request
|
|
from fastapi.responses import Response
|
|
|
|
from ..domain import packets, pm, prints
|
|
from ..pdf import pmreport
|
|
from ..pdf.tracecsv import to_csv
|
|
from .deps import Db, Principal, current_principal, get_db, settings_of
|
|
from .errors import ApiError
|
|
from .packets import FIND
|
|
|
|
|
|
def pm_only(p: Principal = Depends(current_principal)) -> Principal: # noqa: B008
|
|
"""PS-ProjectMgmt (role `pm`) or Admin. Others get 403."""
|
|
if not (p.is_admin or "pm" in p.roles):
|
|
raise ApiError(403, "forbidden", "Project Management only.")
|
|
return p
|
|
|
|
|
|
router = APIRouter(prefix="/api/pm", tags=["pm"], dependencies=[Depends(pm_only)])
|
|
|
|
PERIOD = Query(pm.DEFAULT_PERIOD, pattern="^(30|90|365|all)$")
|
|
|
|
|
|
def _report(
|
|
request: Request,
|
|
d: Db,
|
|
p: Principal,
|
|
find: dict[str, Any],
|
|
project_id: int | None,
|
|
unit_id: int | None,
|
|
period: str,
|
|
expand: str | None = None,
|
|
) -> pm.Report:
|
|
conn = d.conn
|
|
pp = packets.policy_principal_for(conn, p)
|
|
s = settings_of(request)
|
|
return pm.load(
|
|
conn,
|
|
pp,
|
|
{**find, "project_id": project_id, "unit_id": unit_id},
|
|
now=datetime.now(UTC),
|
|
tz=s.site_tz,
|
|
period=period,
|
|
expand=expand,
|
|
)
|
|
|
|
|
|
@router.get("/overview")
|
|
def overview(
|
|
request: Request,
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
find: dict[str, Any] = FIND,
|
|
project_id: int | None = Query(None),
|
|
unit_id: int | None = Query(None),
|
|
period: str = PERIOD,
|
|
) -> dict[str, Any]:
|
|
"""The KPIs, the plain-words summary, the pipeline and the units late or at risk."""
|
|
return pm.overview(_report(request, d, p, find, project_id, unit_id, period))
|
|
|
|
|
|
@router.get("/projects")
|
|
def projects(
|
|
request: Request,
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
find: dict[str, Any] = FIND,
|
|
project_id: int | None = Query(None),
|
|
unit_id: int | None = Query(None),
|
|
period: str = PERIOD,
|
|
expand: str = Query("", max_length=2000),
|
|
) -> dict[str, Any]:
|
|
"""The projects table. Without `expand` only the picked project shows its units; `expand=all` opens every
|
|
project, a comma list of project ids opens those."""
|
|
rep = _report(request, d, p, find, project_id, unit_id, period, expand=expand)
|
|
return {"scope": rep.scope(), "tables": [pm.projects_table(rep)]}
|
|
|
|
|
|
@router.get("/aging")
|
|
def aging(
|
|
request: Request,
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
find: dict[str, Any] = FIND,
|
|
project_id: int | None = Query(None),
|
|
unit_id: int | None = Query(None),
|
|
period: str = PERIOD,
|
|
) -> dict[str, Any]:
|
|
rep = _report(request, d, p, find, project_id, unit_id, period)
|
|
pipe = pm.pipeline(rep)
|
|
return {"scope": rep.scope(), "pipeline": pipe, "tables": pm.aging_tables(rep, pipe)}
|
|
|
|
|
|
@router.get("/hours")
|
|
def hours(
|
|
request: Request,
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
find: dict[str, Any] = FIND,
|
|
project_id: int | None = Query(None),
|
|
unit_id: int | None = Query(None),
|
|
period: str = PERIOD,
|
|
) -> dict[str, Any]:
|
|
rep = _report(request, d, p, find, project_id, unit_id, period)
|
|
return {"scope": rep.scope(), "tables": pm.hours_tables(rep)}
|
|
|
|
|
|
@router.get("/quality")
|
|
def quality(
|
|
request: Request,
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
find: dict[str, Any] = FIND,
|
|
project_id: int | None = Query(None),
|
|
unit_id: int | None = Query(None),
|
|
period: str = PERIOD,
|
|
) -> dict[str, Any]:
|
|
rep = _report(request, d, p, find, project_id, unit_id, period)
|
|
return {"scope": rep.scope(), "tables": pm.quality_tables(rep)}
|
|
|
|
|
|
@router.get("/find")
|
|
def find_jobs(
|
|
request: Request,
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
find: dict[str, Any] = FIND,
|
|
project_id: int | None = Query(None),
|
|
unit_id: int | None = Query(None),
|
|
period: str = PERIOD,
|
|
) -> dict[str, Any]:
|
|
"""The cards in scope, in the find bar's sort (default last updated)."""
|
|
rep = _report(request, d, p, find, project_id, unit_id, period)
|
|
if (find.get("sort") or "updated") != "project":
|
|
f = {**rep.filters, "sort": find.get("sort") or "updated", "limit": pm.CARD_LIMIT, "lite": True}
|
|
cards = packets.list_cards(d.conn, packets.policy_principal_for(d.conn, p), f)
|
|
else:
|
|
cards = rep.cards
|
|
return {"scope": rep.scope(), "packets": cards}
|
|
|
|
|
|
@router.get("/export.csv")
|
|
def export_csv(
|
|
request: Request,
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
find: dict[str, Any] = FIND,
|
|
project_id: int | None = Query(None),
|
|
unit_id: int | None = Query(None),
|
|
period: str = PERIOD,
|
|
table: str = Query("all", max_length=40),
|
|
) -> Response:
|
|
"""One table (by key), the jobs (`jobs`) or every report (`all`) as CSV for Excel: a BOM, CRLF lines and the
|
|
formula guard on every cell. Logged as `report exported`."""
|
|
rep = _report(request, d, p, find, project_id, unit_id, period)
|
|
found = pm.tables_for(rep, table.strip().lower())
|
|
if found is None:
|
|
raise ApiError(404, "not_found", "No such report table.")
|
|
name, tables = found
|
|
who = str(p.user.get("display_name") or p.user.get("sam_account") or "")
|
|
body = "\ufeff" + to_csv(pm.csv_rows(rep, name, tables, who)) + "\r\n"
|
|
day = rep.now.astimezone(pm.zone(rep.tz)).date().isoformat()
|
|
filename = f"PacketStation_{pm.file_stem(name)}_{day}.csv"
|
|
label = rep.scope_label()
|
|
prints.record(
|
|
d.conn,
|
|
p,
|
|
action="report exported",
|
|
kind="report-csv",
|
|
what=f"{name} · CSV · {label}",
|
|
pages=None,
|
|
detail={"name": name, "format": "csv", "scope": label, "period": rep.period},
|
|
)
|
|
return Response(
|
|
content=body.encode("utf-8"),
|
|
media_type="text/csv; charset=utf-8",
|
|
headers={"Content-Disposition": prints.disposition(filename, download=True), "Cache-Control": "no-store"},
|
|
)
|
|
|
|
|
|
@router.get("/report.pdf")
|
|
def report_pdf(
|
|
request: Request,
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
find: dict[str, Any] = FIND,
|
|
project_id: int | None = Query(None),
|
|
unit_id: int | None = Query(None),
|
|
period: str = PERIOD,
|
|
download: int = Query(0, ge=0, le=1),
|
|
) -> Response:
|
|
"""The PESCO production status report (dark format), stamped as a controlled copy on every page. Inline unless
|
|
`download=1`. Logged as `report exported` with the page count."""
|
|
rep = _report(request, d, p, find, project_id, unit_id, period)
|
|
who = prints.who_of(p)
|
|
built = pmreport.build(
|
|
d.conn,
|
|
settings_of(request),
|
|
scope_params={**find, "project_id": project_id, "unit_id": unit_id, "period": period},
|
|
who=who,
|
|
now=rep.now,
|
|
report=rep,
|
|
)
|
|
label = rep.scope_label()
|
|
prints.record(
|
|
d.conn,
|
|
p,
|
|
action="report exported",
|
|
kind="report-pdf",
|
|
what=f"Production status report · PDF · {label}",
|
|
pages=built.pages,
|
|
stamp=built.stamp,
|
|
stamp_sig=built.stamp_sig,
|
|
detail={"name": "Production status report", "format": "pdf", "scope": label, "period": rep.period},
|
|
)
|
|
day = rep.now.astimezone(pm.zone(rep.tz)).date().isoformat()
|
|
return Response(
|
|
content=built.pdf,
|
|
media_type="application/pdf",
|
|
headers={
|
|
"Content-Disposition": prints.disposition(f"PESCO_Production_Status_{day}.pdf", download=bool(download)),
|
|
"Cache-Control": "no-store",
|
|
},
|
|
)
|