Files
PacketStation/server/packetstation/domain/search.py
T
spencermandClaude Opus 5.5 1cd6688247
ci / frontend (pull_request) Successful in 57s
ci / server (pull_request) Successful in 12m33s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 5m4s
Build Seth's changes C1 to C11 (Gitea issues 1, 8, 18, 23, 24, 25, 27, 31, 32, 33, 36)
- 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]>
2026-10-30 13:33:51 -06:00

527 lines
19 KiB
Python

"""The find bar's search and filter terms, shared by every list: the boards, GET /api/packets and the PM reports.
A port of the old filters.js (FLT_STATUS, FLT_FLAGS, FLT_FLAGKIND, fltHay, mineFilter, fltFn, FLT_SORT), applied by
the server so a board never fetches more than it shows. Every term is SQL over CARD_SQL's aliases: p (packets),
u (units), pr (projects), d (departments), st (stations) and wu (the assigned welder). Request text only ever goes
in as a parameter.
"""
from __future__ import annotations
import uuid
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from datetime import date, timedelta
from typing import Any
from .. import policy
from ..auth.groups import SCREENS
from ..text import clean_text
# ---------------------------------------------------------------- vocabulary
OPEN_STATUSES = ("draft", "warehouse", "ready", "assigned", "in_progress", "review", "qc", "complete")
# The find bar's status words. A raw status (or a comma list of them) keeps working.
FLT_STATUS: dict[str, tuple[str, ...]] = {
"draft": ("draft",),
"warehouse": ("warehouse",),
"ready": ("ready",),
"shop": ("ready", "assigned", "in_progress", "review", "qc"),
"station": ("assigned", "in_progress"),
"review": ("review",),
"qc": ("qc",),
"complete": ("complete",),
"filed": ("filed",),
"open": OPEN_STATUSES,
}
FLT_STATUS_LABELS: dict[str, str] = {
"draft": "Ready to release",
"warehouse": "With warehouse (any stage)",
"ready": "Ready to assign",
"shop": "In the shop (ready to Q.C.)",
"station": "At a station (assigned / working)",
"review": "Supervisor review",
"qc": "At Q.C.",
"complete": "Accepted / routed",
"filed": "Filed",
"open": "Everything not filed",
}
FLT_FLAGS = ("co", "short", "overdue", "week", "mine")
FLT_FLAG_LABELS: dict[str, str] = {
"co": "Change order open",
"short": "Shorts open",
"overdue": "Project overdue",
"week": "Due within 7 days",
"mine": "Mine",
}
# The Flag select (drafts only): value -> rule name.
FLT_FLAGKIND: dict[str, str] = {
"any": "flagged",
"rev": "rev_mismatch",
"unconf": "rev_unconfirmed",
"hand": "hand",
"none": "not_released",
"vault": "not_found",
"read": "unreadable",
"wait": "drawing_needed",
"no": "not_flagged",
}
FLT_FLAGKIND_LABELS: dict[str, str] = {
"any": "Flagged (any reason)",
"rev": "Rev mismatch with M-Files",
"unconf": "Rev not confirmed yet",
"hand": "Made or uploaded by hand",
"none": "Still in drafting",
"vault": "Drawing not found in M-Files",
"read": "Drawing couldn't be read",
"wait": "Drawing being looked up",
"no": "Not flagged",
}
# The old raw values (phase 2 to 7) that keep working.
FLAG_ON = ("1", "true", "yes", "flagged")
FLAG_OFF = ("0", "false")
# The status texts of the frontend's lib/packets.ts STATUS_LABEL (search matches these; the PM history groups by
# them).
STATUS_LABEL: dict[str, str] = {
"draft": "Ready to release",
"warehouse": "With warehouse",
"ready": "Ready to assign",
"assigned": "Sent to station",
"in_progress": "Working",
"review": "Supervisor review",
"qc": "Q.C. inspection",
"complete": "Routed",
"filed": "Filed in data book",
}
TIE = "p.updated_at DESC, p.id DESC"
SORTS: dict[str, str] = {
"updated": TIE,
"project": 'pr.number COLLATE "C", u.lot COLLATE "C", p.op COLLATE "C", p.id',
"due": f"coalesce(u.due, pr.due) ASC NULLS LAST, {TIE}",
"status": f"p.status, {TIE}",
"dept": f"p.dept_code, {TIE}",
}
SORT_LABELS: dict[str, str] = {
"updated": "Last updated",
"project": "Project / lot",
"due": "Due date",
"status": "Status",
"dept": "Department",
}
WORDS_MAX = 8
WORD_MAX = 60
# Words that match everything, so "lot 52" and "rev B" work as the old hay text did.
WORDS_ALWAYS = ("lot", "rev")
MINE_SCREENS = ("clerical", "warehouse", "supervisor", "stall", "qc", "pm", "admin")
LEAD = 2
def like(word: str) -> str:
"""An ILIKE pattern for a word anywhere, with %, _ and the backslash escaped."""
return "%" + word.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
# ---------------------------------------------------------------- status
def status_terms(value: str) -> list[str]:
"""The raw statuses a status value stands for: a find bar word expands, a raw status stays, in a comma list."""
out: list[str] = []
for part in str(value or "").split(","):
s = part.strip().lower()
if not s:
continue
for st in FLT_STATUS.get(s, (s,)):
if st not in out:
out.append(st)
return out
# ---------------------------------------------------------------- search
_STATUS_CASE = (
"(CASE p.status::text "
+ " ".join(f"WHEN '{k}' THEN '{v}'" for k, v in STATUS_LABEL.items())
+ " ELSE p.status::text END)"
)
# One fragment per field, each with exactly one %s (the word's pattern).
_FIELDS: tuple[str, ...] = (
"pr.number ILIKE %s",
"pr.name ILIKE %s",
"(pr.number || u.lot) ILIKE %s",
"(pr.number || ltrim(u.lot, '0')) ILIKE %s",
"u.lot ILIKE %s",
"ltrim(u.lot, '0') ILIKE %s",
"u.name ILIKE %s",
"p.op ILIKE %s",
"p.title ILIKE %s",
"p.drawing ILIKE %s",
"p.rev ILIKE %s",
"p.wps ILIKE %s",
"p.customer ILIKE %s",
"pr.customer ILIKE %s",
"pr.po ILIKE %s",
"d.name ILIKE %s",
"st.name ILIKE %s",
"wu.display_name ILIKE %s",
"p.welder_stamp ILIKE %s",
"wu.welder_stamp ILIKE %s",
_STATUS_CASE + " ILIKE %s",
"array_to_string(p.flags, ' ') ILIKE %s",
"EXISTS (SELECT 1 FROM project_aliases pa WHERE pa.project_id = pr.id"
" AND (pa.unit_id IS NULL OR pa.unit_id = u.id) AND pa.alias ILIKE %s)",
"EXISTS (SELECT 1 FROM packet_work_orders wo WHERE wo.packet_id = p.id"
" AND (wo.wo_base || '/' || wo.lot || '/' || wo.split || '/' || wo.sub) ILIKE %s)",
"EXISTS (SELECT 1 FROM bom_lines b WHERE b.packet_id = p.id"
" AND concat_ws(' ', b.heat, b.part_id, b.description, b.material) ILIKE %s)",
"EXISTS (SELECT 1 FROM change_orders c WHERE c.packet_id = p.id"
" AND concat_ws(' ', 'CO-' || c.co_no, c.ecn) ILIKE %s)",
"EXISTS (SELECT 1 FROM welds w LEFT JOIN users ww ON ww.id = w.welder_id WHERE w.packet_id = p.id"
" AND w.removed_at IS NULL AND concat_ws(' ', w.stamp, ww.display_name) ILIKE %s)",
)
def search_words(q: str) -> list[str]:
"""The words of a search: at most 8, each cut at 60 characters, with "lot" and "rev" left out."""
words = [w[:WORD_MAX] for w in clean_text(q or "", 1000).split()[:WORDS_MAX]]
return [w for w in words if w.lower() not in WORDS_ALWAYS]
def search_terms(q: str) -> tuple[list[str], list[Any]]:
"""One "(...)" term per word; every word must match some field. Child matches are EXISTS terms, so a packet
is never listed twice."""
from .packets import FLAG_SQL
where: list[str] = []
params: list[Any] = []
for word in search_words(q):
pat = like(word)
parts = list(_FIELDS)
# The word "flagged" (or a part of it) finds every flagged draft, as the old hay text did.
parts.append(f"({FLAG_SQL} AND 'flagged' ILIKE %s)")
where.append("(" + " OR ".join(parts) + ")")
params += [pat] * len(parts)
return where, params
# ---------------------------------------------------------------- Mine
@dataclass(frozen=True)
class MineScope:
screen: str
principal: policy.Principal
station_id: int | None
def screens_of(pp: policy.Principal) -> list[str]:
"""The screens a policy principal may open, in SCREENS order (admin: every screen)."""
if pp.admin:
return list(SCREENS)
have: set[str] = set()
if "CL" in pp.support:
have.add("clerical")
if "WH" in pp.support:
have.add("warehouse")
if "QC" in pp.support:
have.add("qc")
if pp.pm:
have.add("pm")
for lvl in pp.shop.values():
have.add("stall")
if int(lvl) >= LEAD:
have.add("supervisor")
return [s for s in SCREENS if s in have]
def mine_scope(pp: policy.Principal, screen: str | None, station_id: int | None) -> MineScope:
"""The screen Mine is decided for: the one the caller names, else the first screen the roles give."""
s = str(screen or "").strip().lower()
if s not in MINE_SCREENS:
found = screens_of(pp)
s = found[0] if found else "pm"
return MineScope(screen=s, principal=pp, station_id=station_id)
# The statuses a packet shows on the Stall screen: assigned, in progress and submitted for review.
STALL_STATUSES = ("assigned", "in_progress", "review")
def mine_term(m: MineScope) -> tuple[str | None, list[Any]]:
"""The Mine rule (the old mineFilter) for the screen, or None when it is everything (PM, admin)."""
pp = m.principal
if m.screen == "clerical":
return "(p.status = 'draft' OR (p.status = 'complete' AND p.routed_to = 'databook'))", []
if m.screen == "warehouse":
return (
"(p.status = 'warehouse' OR EXISTS (SELECT 1 FROM bom_shorts ms WHERE ms.packet_id = p.id"
" AND ms.closed_at IS NULL))",
[],
)
if m.screen == "qc":
return "p.status = 'qc'", []
if m.screen == "supervisor":
depts = sorted(code for code, lvl in pp.shop.items() if int(lvl) >= LEAD)
if not depts:
return (None, []) if pp.admin else ("false", [])
return "p.dept_code = ANY(%s)", [depts]
if m.screen == "stall":
# Change C11 (Gitea issue 36): the packets assigned to me, at whatever station the supervisor picked. No
# picked station is needed; `station_id` is ignored here.
if not pp.user_id:
return "false", []
return "(p.welder_id = %s::uuid AND p.status::text = ANY(%s))", [str(pp.user_id), list(STALL_STATUSES)]
return None, []
# ---------------------------------------------------------------- quick flags and flag kinds
def flag_terms(flags: Iterable[str], *, today: date, mine: MineScope | None) -> tuple[list[str], list[Any]]:
"""The quick flag chips: every chip on must hold."""
where: list[str] = []
params: list[Any] = []
for f in flags:
if f == "co":
where.append("EXISTS (SELECT 1 FROM v_open_cos fc WHERE fc.packet_id = p.id)")
elif f == "short":
where.append("EXISTS (SELECT 1 FROM bom_shorts fs WHERE fs.packet_id = p.id AND fs.closed_at IS NULL)")
elif f == "overdue":
where.append("coalesce(u.due, pr.due) < %s")
params.append(today)
elif f == "week":
where.append("coalesce(u.due, pr.due) <= %s")
params.append(today + timedelta(days=7))
elif f == "mine" and mine is not None:
term, p = mine_term(mine)
if term is not None:
where.append(term)
params += p
return where, params
def _kind_term(kinds: list[str]) -> tuple[str, list[Any]]:
"""Drafts carrying one of the flag kinds. A visual draft whose flags are empty but whose drawing state still
names the problem counts too, the same way flag_of reads it."""
from .packets import STATE_KINDS
states = [st for st, k in STATE_KINDS.items() if k in kinds]
return (
"(p.status = 'draft' AND (p.flags && %s::text[] OR (cardinality(p.flags) = 0 AND p.source_kind = 'visual'"
" AND p.drawing_state::text = ANY(%s))))",
[kinds, states],
)
def flagkind_term(value: str) -> tuple[str, list[Any]] | None:
"""The Flag select's term, or None for no filter. The old raw values keep working."""
from .packets import FLAG_SQL, HAND_UPLOAD_FLAGS, RX_FLAG_KIND
v = str(value or "").strip().lower()
if not v or v == "all":
return None
if v == "any" or v in FLAG_ON:
return FLAG_SQL, []
if v == "no" or v in FLAG_OFF:
return "NOT " + FLAG_SQL, []
if v == "hand":
return "(p.status = 'draft' AND (p.source_kind = 'hand' OR p.flags && %s::text[]))", [list(HAND_UPLOAD_FLAGS)]
if v in ("hand_upload", "hand_uploads"):
return "(p.status = 'draft' AND p.flags && %s::text[])", [list(HAND_UPLOAD_FLAGS)]
if v == "unconf":
# The drawing is attached but the drawing job has not confirmed the rev with M-Files yet (the read model
# keeps that in visual->>'rev_confirmed').
return (
"(p.status = 'draft' AND p.source_kind = 'visual' AND p.drawing_state = 'attached'"
" AND (p.visual->>'rev_confirmed') IS DISTINCT FROM 'true')",
[],
)
if v == "rev":
return _kind_term(["rev_mismatch"])
if v == "none":
return _kind_term(["not_released"])
if v == "vault":
return _kind_term(["not_found"])
if v == "read":
return _kind_term(["lookup_failed", "bad_rev"])
if v == "wait":
return _kind_term(["drawing_needed"])
if RX_FLAG_KIND.match(v):
return _kind_term([v])
return "false", []
# ---------------------------------------------------------------- sort and facets
def order_by(sort: str | None) -> str:
return SORTS.get(str(sort or "").strip().lower(), TIE)
# {scope} is fixed terms from _scope, never request text.
FACET_STATIONS_SQL = """
SELECT DISTINCT st.id, st.name, st.dept_code FROM packets p JOIN units u ON u.id = p.unit_id
JOIN stations st ON st.id = p.station_id WHERE p.removed_at IS NULL{scope} ORDER BY st.name, st.id
"""
FACET_WORKERS_SQL = """
SELECT DISTINCT wu.id, wu.display_name FROM packets p JOIN units u ON u.id = p.unit_id
JOIN users wu ON wu.id = p.welder_id AND wu.profile_active
WHERE p.removed_at IS NULL{scope} ORDER BY wu.display_name, wu.id
"""
def _scope(project_id: int | None, unit_id: int | None, ids: list[int] | None) -> tuple[str, list[Any]]:
terms = ""
params: list[Any] = []
if project_id is not None:
terms += " AND u.project_id = %s"
params.append(int(project_id))
if unit_id is not None:
terms += " AND p.unit_id = %s"
params.append(int(unit_id))
if ids is not None:
terms += " AND p.id = ANY(%s)"
params.append([int(i) for i in ids])
return terms, params
def facets(
conn: Any, *, project_id: int | None = None, unit_id: int | None = None, ids: Iterable[int] | None = None
) -> dict[str, list[dict[str, Any]]]:
"""The distinct stations and assigned welders among the packets in the project and unit scope (and, for a
board, among the board's own packets), ordered by name. The other filters do not narrow them."""
from .packets import rows
scope, params = _scope(project_id, unit_id, None if ids is None else list(ids))
stations = rows(conn, FACET_STATIONS_SQL.format(scope=scope), params)
workers = rows(conn, FACET_WORKERS_SQL.format(scope=scope), params)
return {
"stations": [{"id": r["id"], "name": r["name"], "dept_code": r["dept_code"]} for r in stations],
"workers": [{"id": str(r["id"]), "display_name": r["display_name"]} for r in workers],
}
# ---------------------------------------------------------------- the filters dict
def _int_or_none(v: Any) -> int | None:
if v is None or v == "":
return None
try:
return int(v)
except (TypeError, ValueError):
return None
def _uuid_text(v: Any) -> str | None:
"""A welder id as uuid text; anything that is not a uuid becomes a value no packet has."""
s = str(v or "").strip()
if not s:
return None
try:
return str(uuid.UUID(s))
except ValueError:
return "00000000-0000-0000-0000-000000000000"
def parse_flags(value: Any) -> list[str]:
out: list[str] = []
for part in str(value or "").split(","):
f = part.strip().lower()
if f in FLT_FLAGS and f not in out:
out.append(f)
return out
def parse_filters(
params: Mapping[str, Any], pp: policy.Principal, *, screen: str | None, today: date | None = None
) -> dict[str, Any]:
"""The filters dict list_cards takes, from the find bar's query values. `screen` is the default for Mine (the
board's own screen); `mine_screen` in the params wins when given."""
flags = parse_flags(params.get("flags"))
mine_station = _int_or_none(params.get("mine_station_id"))
mine = mine_scope(pp, str(params.get("mine_screen") or "") or screen, mine_station) if "mine" in flags else None
out: dict[str, Any] = {
"status": str(params.get("status") or "").strip(),
"dept": (str(params.get("dept") or "").strip() or None),
"unit_id": _int_or_none(params.get("unit_id")),
"project_id": _int_or_none(params.get("project_id")),
"q": clean_text(params.get("q") or "", 200),
"flag": str(params.get("flag") or "").strip().lower(),
"flags": flags,
"station_id": _int_or_none(params.get("station_id")),
"welder_id": _uuid_text(params.get("welder_id")),
"sort": str(params.get("sort") or "updated").strip().lower(),
"mine": mine,
"today": today or date.today(),
}
if out["dept"] and out["dept"].lower() == "all":
out["dept"] = None
if out["status"].lower() == "all":
out["status"] = ""
if out["flag"] == "all":
out["flag"] = ""
return out
def narrowing(filters: Mapping[str, Any]) -> bool:
"""True when a filter other than the project and unit pick is set (the old fset)."""
return bool(
filters.get("q")
or filters.get("status")
or filters.get("also_status")
or filters.get("dept")
or filters.get("flag")
or filters.get("flags")
or filters.get("station_id") is not None
or filters.get("welder_id")
)
def filter_where(filters: Mapping[str, Any]) -> tuple[list[str], list[Any]]:
"""The find bar terms of a filters dict: q, the user's status (`also_status` on a board, whose own status is
fixed), station, welder, the quick flags (Mine included) and the flag kind."""
where: list[str] = []
params: list[Any] = []
also = status_terms(str(filters.get("also_status") or ""))
if also:
where.append("p.status::text = ANY(%s)")
params.append(also)
if filters.get("station_id") is not None:
where.append("p.station_id = %s")
params.append(int(filters["station_id"]))
if filters.get("welder_id"):
where.append("p.welder_id = %s::uuid")
params.append(str(filters["welder_id"]))
flags = list(filters.get("flags") or [])
if flags:
today = filters.get("today") or date.today()
mine = filters.get("mine")
w, p = flag_terms(flags, today=today, mine=mine if isinstance(mine, MineScope) else None)
where += w
params += p
fk = flagkind_term(str(filters.get("flag") or ""))
if fk is not None:
where.append(fk[0])
params += fk[1]
w, p = search_terms(str(filters.get("q") or ""))
where += w
params += p
return where, params
def board_filters(filters: Mapping[str, Any]) -> dict[str, Any]:
"""The filters for a board whose columns fix their own statuses: the user's status moves to `also_status` and
the project, unit and limit are left to the board."""
out = dict(filters)
out["also_status"] = out.pop("status", "") or ""
out.pop("sort", None)
return out