Phase 3: VISUAL feed and M-Files drawings in the worker
ci / frontend (push) Successful in 30s
ci / server (push) Successful in 4m12s
ci / image (push) Successful in 46s
ci / e2e (push) Successful in 1m24s

Migration 0004 (VISUAL and drawing caches, go-live ignored list, resource
map, job tables, order-derived columns with provenance; work order key
includes the operation sequence). The real worker on APScheduler with
settings re-read each fire, pause, run now, advisory locks, job_runs and a
heartbeat. The visual-feed job (five VisualKit reads, COPY and MERGE cache,
suspect-batch guard, the apply in one transaction: drafts, change orders,
retire-moved, work order gone, closed and cancelled observed, projects and
units from order links, auto-release) and the fingerprint. The drawing-lookup
job (queue, budget, download on version change, sha256 into assets, decision
table, staging for rev approval, drawing-origin change orders). Clerical From
VISUAL panel, flags, rev approval, hand uploads, change-order review; Admin
Feed settings and resource map. Synthetic fixture generator and a
fixture-driven worker in CI with a Playwright scenario. Docs updated.

Verified: 3249 server tests and 13 Playwright scenarios on the docker-1 CI
stack, 76 frontend tests, doctor at 0004; one feed and one drawing run
against VISUAL DEV and the vault from the developer machine (counts in the
changelog).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-16 14:45:38 -06:00
co-authored by Claude Fable 5.1
parent 16950055e3
commit 591fe90076
103 changed files with 16063 additions and 151 deletions
+17
View File
@@ -0,0 +1,17 @@
"""Drawings from M-Files, read-only: the lookup, the decision table, the queue and the fetch into assets.
Packet Station never writes to M-Files. MFilesKit enforces that in its own code; this package only calls its
read methods (`DrawingLookup.status`, `DrawingLookup.download`).
"""
from __future__ import annotations
# The packet flags this package owns. The drawing job replaces only these and keeps every other flag.
DRAWING_FLAGS: tuple[str, ...] = (
"drawing_needed",
"rev_mismatch",
"not_released",
"not_found",
"lookup_failed",
"bad_rev",
)
+190
View File
@@ -0,0 +1,190 @@
"""The drawing decision table. Pure: no database, no M-Files.
`decide(target, found, same_file=...)` says what the drawing job does to one packet for one lookup. Note texts are
the app's own (from the old revMismatch and draftLine in visual.js, "-" in place of the em-dash), never M-Files'
note passed through, except inside the lookup_failed text.
Rows, in the order they are tried:
1. lookup failed -> flag lookup_failed, keep what is attached (state lookup_failed only when it was needed).
2. past draft, a pending feed change order waits for this drawing at the released rev -> stage_for_co.
Past draft, the pending change order already stages this drawing at the released rev -> unchanged, or
stage_for_co again when M-Files has a different file for that rev.
3. no released rev, a draft rev or a drafting assignment -> flag not_released with the drafting status.
4. no released rev otherwise -> flag not_found.
5. VISUAL rev not a number (and no approved rev equal to M-Files') -> flag bad_rev, not attached.
6. revs differ (and no approved rev equal to M-Files') -> flag rev_mismatch. The job stages the M-Files copy
for Clerical Supervisor approval on drafts only.
7. revs match, draft with a hand drawing -> unchanged (the feed already took the hand drawing off on a rev move).
8. revs match, draft with this very file attached at this rev -> unchanged.
9. revs match, draft -> attach ("sheet of drawing <base>" for an H sheet).
10. revs match, past draft, nothing attached -> unchanged.
11. revs match, past draft, same file as attached -> unchanged.
12. revs match, past draft, a pending change order already stages this file, or Clerical rejected a change order
for this file -> unchanged.
13. revs match, past draft, attached rev lower -> open_co "M-Files released rev N of <dwg>; this packet has rev M".
14. revs match, past draft, attached rev equal, new file -> open_co "M-Files re-released rev N of <dwg> (new file)".
15. revs match, past draft, attached rev higher or not a number -> unchanged.
"""
from __future__ import annotations
from dataclasses import dataclass
from .lookup import Found
LOOKUP_FAILED = "lookup_failed"
NOT_RELEASED = "not_released"
NOT_FOUND = "not_found"
BAD_REV = "bad_rev"
REV_MISMATCH = "rev_mismatch"
ATTACH = "attach"
UNCHANGED = "unchanged"
OPEN_CO = "open_co"
STAGE_FOR_CO = "stage_for_co"
KINDS = (ATTACH, UNCHANGED, REV_MISMATCH, NOT_RELEASED, NOT_FOUND, LOOKUP_FAILED, BAD_REV, OPEN_CO, STAGE_FOR_CO)
FLAG_KINDS = (LOOKUP_FAILED, NOT_RELEASED, NOT_FOUND, BAD_REV, REV_MISMATCH)
# drawing_state enum values (0003). bad_rev has no state of its own: it is a rev mismatch Clerical can approve.
STATE_OF = {NOT_RELEASED: "not_released", NOT_FOUND: "not_found", BAD_REV: "rev_mismatch", REV_MISMATCH: "rev_mismatch"}
HELD_STATES = ("attached", "hand")
NOT_FOUND_NOTE = "Drawing not in M-Files - check the drawing number in VISUAL"
_FAILED_PREFIX = "M-Files lookup failed: "
@dataclass(frozen=True)
class Target:
dwg: str
visual_rev: str
attached_rev: str | None # rev of the live drawing pages; None when no drawing page is attached
attached_sha: bytes | None
status: str # packet status
drawing_state: str
has_hand_drawing: bool
rev_ok: str | None # the rev Clerical approved for this VISUAL rev, if any
# Additions to the contract, all defaulted: the pending feed change order (origin visual or drawing), if any.
co_waiting: tuple[str, str] | None = None # (dwg, rev) the pending CO waits for
co_staged: tuple[str, str] | None = None # (dwg, rev) the pending CO already stages
co_pending: bool = False
co_staged_sha: bytes | None = None # sha256 of the file the pending CO already stages
rejected_sha: bytes | None = None # sha256 staged on the last drawing-origin CO Clerical rejected
@dataclass(frozen=True)
class Decision:
kind: str
flag: str | None
drawing_state: str
note: str
def _int(v: object) -> int | None:
s = "" if v is None else str(v).strip()
return int(s) if s.isdigit() else None
def draft_line(f: Found) -> str:
"""The old draftLine: "rev N <where>", or "in drafting, no released PDF yet", or ""."""
if f.draft_rev is not None and f.draft_where:
return f"rev {f.draft_rev} {f.draft_where}"
if f.in_drafting:
return "in drafting, no released PDF yet"
if f.draft_rev is not None:
return f"rev {f.draft_rev}"
return ""
def failed_note(f: Found) -> str:
detail = (f.note or "").strip()
if detail.startswith(_FAILED_PREFIX): # MFilesKit's own note already carries the prefix
detail = detail[len(_FAILED_PREFIX) :]
return f"{_FAILED_PREFIX}{detail or 'no detail'}"
def rev_confirmed(t: Target, f: Found) -> bool:
"""The old revConfirmed: M-Files' released rev is the rev VISUAL calls, or Clerical approved that rev."""
if not f.ok or f.rev is None:
return False
v = _int(t.visual_rev)
if v is not None and v == f.rev:
return True
return t.rev_ok is not None and str(t.rev_ok).strip() == str(f.rev)
def decide(t: Target, f: Found, *, same_file: bool, fetched_sha: bytes | None = None) -> Decision:
"""What to do to one packet.
`same_file`: the M-Files file has the sha256 of the attached drawing asset. `fetched_sha`: the sha256 of the
M-Files file, when it was fetched (row 12 compares it with the pending and the rejected change orders).
"""
held = t.drawing_state in HELD_STATES
draft = t.status == "draft"
def flag(kind: str, note: str) -> Decision:
return Decision(kind, kind, t.drawing_state if held else STATE_OF[kind], note)
def keep(kind: str, note: str = "") -> Decision:
return Decision(kind, None, t.drawing_state, note)
# 1
if not f.ok:
return Decision(
LOOKUP_FAILED,
LOOKUP_FAILED,
LOOKUP_FAILED if t.drawing_state == "needed" else t.drawing_state,
failed_note(f),
)
# 2
if not draft and f.rev is not None and t.co_waiting is not None:
w_dwg, w_rev = t.co_waiting
if w_dwg.strip() == t.dwg.strip() and str(w_rev).strip() == str(f.rev):
return keep(STAGE_FOR_CO, f"Drawing for the change order is in M-Files (rev {f.rev})")
if not draft and f.rev is not None and t.co_staged is not None:
s_dwg, s_rev = t.co_staged
if s_dwg.strip() == t.dwg.strip() and str(s_rev).strip() == str(f.rev):
if fetched_sha is not None and t.co_staged_sha is not None and fetched_sha != t.co_staged_sha:
return keep(STAGE_FOR_CO, f"New file for rev {f.rev} in M-Files; staged again for the change order")
return keep(UNCHANGED, f"Rev {f.rev} is staged on the pending change order")
# 3, 4
if f.rev is None:
d = draft_line(f)
if d:
on = f" (sheet of {f.base})" if f.base else ""
return flag(NOT_RELEASED, f"Not released in M-Files yet{on} - drafting status: {d}")
return flag(NOT_FOUND, NOT_FOUND_NOTE)
approved = t.rev_ok is not None and str(t.rev_ok).strip() == str(f.rev)
vrev = (t.visual_rev or "").strip()
v = _int(vrev)
if not approved:
# 5
if v is None:
return flag(BAD_REV, f"VISUAL rev {vrev or '-'} is not a number; M-Files has rev {f.rev} - not attached")
# 6
if v != f.rev:
d = f"; drafting status: {draft_line(f)}" if f.draft_rev is not None and f.draft_rev > f.rev else ""
return flag(REV_MISMATCH, f"Rev mismatch: VISUAL calls rev {vrev}, M-Files has rev {f.rev}{d}")
sheet = f"sheet of drawing {f.base}" if f.base else ""
if draft:
# 7
if t.has_hand_drawing:
return keep(UNCHANGED, "A drawing uploaded by hand stays at the same rev")
# 8
if t.drawing_state == "attached" and same_file and t.attached_rev == str(f.rev):
return keep(UNCHANGED, sheet)
# 9
return Decision(ATTACH, None, "attached", sheet)
# 10, 11
if t.attached_rev is None or same_file:
return keep(UNCHANGED, sheet)
# 12
if fetched_sha is not None and fetched_sha in (t.co_staged_sha, t.rejected_sha):
return keep(UNCHANGED, sheet)
att = _int(t.attached_rev)
if att is None or att > f.rev:
return keep(UNCHANGED, sheet) # 15
if att < f.rev:
reason = f"M-Files released rev {f.rev} of {t.dwg}; this packet has rev {att}"
else:
reason = f"M-Files re-released rev {f.rev} of {t.dwg} (new file)"
return keep(OPEN_CO, reason)
+118
View File
@@ -0,0 +1,118 @@
"""The fixture lookup for CI and tests: `dir/drawings.<state>.json` and the PDFs it names.
`dir/state` holds the state number (default "1"). The JSON maps a drawing number to
`{"rev": 3, "file": "L073762X r3.pdf", "draft_rev": null, "draft_where": "", "in_drafting": false, "ok": true,
"note": "", "obj_id": 1, "obj_version": 1, "file_id": 1, "file_version": 1}`; every key is optional. A drawing
not in the file is not found, after the same H-sheet fallback MFilesKit uses (K978-01-6H13 -> K978-01-6).
`download` copies `dir/<file>`.
"""
from __future__ import annotations
import json
import re
import shutil
from pathlib import Path
from typing import Any
from .lookup import DrawingFetchError, Found, failed
NOT_FOUND_NOTE = "not in M-Files - check the drawing number in VISUAL"
_H_SHEET = re.compile(r"^(.*\d)H\d{1,3}$")
def base_drawing(dwg: str) -> str | None:
m = _H_SHEET.match(dwg)
return m.group(1) if m else None
def _int(v: Any) -> int | None:
if v is None or isinstance(v, bool):
return None
try:
return int(str(v).strip())
except ValueError:
return None
class FixtureLookup:
def __init__(self, directory: Path, state: str | None = None) -> None:
self.dir = Path(directory)
if state is None:
try:
state = (self.dir / "state").read_text(encoding="utf-8").strip() or "1"
except OSError:
state = "1"
self.state = state
self._data: dict[str, dict[str, Any]] | None = None
self._error: str | None = None
def _load(self) -> dict[str, dict[str, Any]]:
if self._data is None:
path = self.dir / f"drawings.{self.state}.json"
try:
raw = json.loads(path.read_text(encoding="utf-8"))
self._data = {str(k).strip(): v for k, v in raw.items() if isinstance(v, dict)}
except (OSError, ValueError, AttributeError) as e:
self._error = f"fixture {path.name} could not be read: {type(e).__name__}"
self._data = {}
return self._data
def status(self, dwg: str) -> Found:
dwg = (dwg or "").strip()
data = self._load()
if self._error:
return failed(dwg, self._error)
if not dwg:
return Found(dwg="", note="no drawing number")
entry, base = data.get(dwg), None
if entry is None:
b = base_drawing(dwg)
if b and b in data:
entry, base = data[b], b
if entry is None:
return Found(dwg=dwg, note=NOT_FOUND_NOTE)
if entry.get("ok") is False:
return failed(dwg, str(entry.get("note") or "M-Files lookup failed: fixture says so"))
rev = _int(entry.get("rev"))
file = entry.get("file") or None
size = None
if file and rev is not None:
p = self._file(str(file))
size = p.stat().st_size if p is not None and p.is_file() else None
has_file = rev is not None and bool(file)
return Found(
dwg=dwg,
ok=True,
rev=rev,
name=str(entry.get("name") or file or "") or None if rev is not None else None,
obj_id=_int(entry.get("obj_id", 1)) if has_file else None,
obj_version=_int(entry.get("obj_version", 1)) if has_file else None,
file_id=_int(entry.get("file_id", 1)) if has_file else None,
file_version=_int(entry.get("file_version", 1)) if has_file else None,
file_size=size,
base=base,
draft_rev=_int(entry.get("draft_rev")),
draft_where=str(entry.get("draft_where") or ""),
in_drafting=bool(entry.get("in_drafting")),
note=str(entry.get("note") or ""),
)
def _file(self, name: str) -> Path | None:
root = self.dir.resolve()
p = (root / name).resolve()
return p if p.is_relative_to(root) else None
def download(self, found: Found, dest: Path) -> int:
entry = self._load().get(found.base or found.dwg) or self._load().get(found.dwg) or {}
name = entry.get("file")
src = self._file(str(name)) if name else None
if src is None or not src.is_file():
raise DrawingFetchError(f"{found.dwg}: fixture file {name!r} is missing")
dest = Path(dest)
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(src, dest)
return dest.stat().st_size
def describe(self) -> str:
return f"fixture {self.dir} state {self.state}"
+125
View File
@@ -0,0 +1,125 @@
"""Copy a released drawing PDF from M-Files into assets.
Downloads only when the M-Files identity (object id and version, file id and version) differs from what the
`drawing_lookups` row last stored, or that row has no asset (or the asset's file is gone). The download goes to
ASSETS_DIR/tmp/<uuid>; the PDF magic and the size limit are checked, then `assets.store_file` hashes it in chunks,
fsyncs and renames it into place, and reuses an existing asset row with the same sha256 and size.
Read-only to M-Files: only `Lookup.download` is called.
"""
from __future__ import annotations
import contextlib
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from psycopg.rows import dict_row
from ..api.errors import ApiError
from ..domain import assets
from .lookup import Found, Lookup
PDF_MAGIC = b"%PDF-"
class FetchError(Exception):
"""The drawing could not be copied. The message is safe to log."""
@dataclass(frozen=True, slots=True)
class FetchResult:
asset_id: uuid.UUID
sha256: bytes
pages: int
downloaded: bool # False when the stored asset was still current
name: str | None = None
reused: bool = False # downloaded, but the same bytes were already stored
def _current(conn: Any, settings: Any, found: Found, dwg: str) -> FetchResult | None:
with conn.cursor(row_factory=dict_row) as cur:
row = cur.execute(
"SELECT l.obj_id, l.obj_version, l.file_id, l.file_version, l.asset_id, a.sha256, a.pages,"
" a.storage_key, a.original_name FROM drawing_lookups l LEFT JOIN assets a ON a.id = l.asset_id"
" WHERE l.dwg = %s",
(dwg,),
).fetchone()
if row is None or row["asset_id"] is None or row["sha256"] is None:
return None
if (row["obj_id"], row["obj_version"], row["file_id"], row["file_version"]) != found.identity:
return None
if None in found.identity:
return None
try:
if not assets.path_of(settings, str(row["storage_key"])).is_file():
return None
except ApiError:
return None
return FetchResult(
asset_id=row["asset_id"],
sha256=bytes(row["sha256"]),
pages=int(row["pages"] or 0),
downloaded=False,
name=row["original_name"],
)
def fetch(conn: Any, settings: Any, lookup: Lookup, found: Found, *, dwg: str) -> FetchResult:
"""The asset for M-Files' released file of `dwg`. Raises FetchError."""
if not found.ok or found.rev is None:
raise FetchError(f"{dwg}: no released file in M-Files")
cur = _current(conn, settings, found, dwg)
if cur is not None:
return cur
limit = int(settings.upload_max_bytes)
if found.file_size is not None and found.file_size > limit:
raise FetchError(f"{dwg}: the M-Files file is {found.file_size} bytes, over the {limit} byte limit")
tmp_dir = Path(settings.assets_dir) / "tmp"
tmp_dir.mkdir(parents=True, exist_ok=True)
tmp = tmp_dir / uuid.uuid4().hex
try:
try:
lookup.download(found, tmp)
except LookupError as e:
raise FetchError(str(e)) from None
except OSError as e:
raise FetchError(f"{dwg}: could not write the download: {type(e).__name__}") from None
try:
size = tmp.stat().st_size
with tmp.open("rb") as f:
head = f.read(len(PDF_MAGIC))
except OSError:
raise FetchError(f"{dwg}: the download left no file") from None
if size > limit:
raise FetchError(f"{dwg}: the M-Files file is {size} bytes, over the {limit} byte limit")
if head != PDF_MAGIC:
raise FetchError(f"{dwg}: the M-Files file is not a PDF")
name = found.name or f"{dwg} r{found.rev}.pdf"
source_ref = {
"dwg": dwg,
"obj_id": found.obj_id,
"obj_version": found.obj_version,
"file_id": found.file_id,
"file_version": found.file_version,
"rev": found.rev,
}
try:
row = assets.store_file(
conn, settings, tmp, mime=assets.PDF, original_name=name, source="mfiles", source_ref=source_ref
)
except ApiError as e:
raise FetchError(f"{dwg}: {e.detail}") from None
return FetchResult(
asset_id=row["id"],
sha256=bytes(row["sha256"]),
pages=int(row["pages"] or 0),
downloaded=True,
name=row.get("original_name") or name,
reused=bool(row.get("reused")),
)
finally:
with contextlib.suppress(OSError):
tmp.unlink()
+183
View File
@@ -0,0 +1,183 @@
"""The drawing lookup the job talks to: M-Files through MFilesKit, or the fixture files (fake.py).
One M-Files `Vault` per worker process (each sign-in can use a licence), kept in the dict the scheduler passes
(`JobContext.shared`) or, when there is none, in this module's own cache keyed by MFILES_URL. A new
`DrawingLookup` per run, because its result cache lives on the instance.
Read-only. Nothing here writes to M-Files.
"""
from __future__ import annotations
import logging
import os
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol
if TYPE_CHECKING:
from mfileskit.drawings import DrawingStatus
from mfileskit.vault import Vault
from ..settings import Settings
log = logging.getLogger(__name__)
VAULT_KEY = "mfiles_vault"
NOTE_MAX = 500
class DrawingFetchError(LookupError):
"""A download did not work. The message is safe to log (no credentials)."""
@dataclass(slots=True)
class Found:
"""What M-Files has for one drawing number: the subset the decision needs, plus identity for change detection."""
dwg: str
ok: bool = True
rev: int | None = None
name: str | None = None
obj_id: int | None = None
obj_version: int | None = None
file_id: int | None = None
file_version: int | None = None
file_size: int | None = None
base: str | None = None
draft_rev: int | None = None
draft_where: str = ""
in_drafting: bool = False
note: str = ""
@property
def identity(self) -> tuple[int | None, int | None, int | None, int | None]:
return (self.obj_id, self.obj_version, self.file_id, self.file_version)
def failed(dwg: str, note: str) -> Found:
return Found(dwg=dwg, ok=False, note=note[:NOTE_MAX])
class Lookup(Protocol):
def status(self, dwg: str) -> Found: ...
def download(self, found: Found, dest: Path) -> int:
"""Write the released PDF to `dest`; bytes written. Raises DrawingFetchError (a LookupError)."""
...
def describe(self) -> str: ...
def from_status(st: DrawingStatus) -> Found:
"""MFilesKit's DrawingStatus -> Found."""
ov, fi = st.obj_ver, st.file
return Found(
dwg=st.drawing,
ok=bool(st.ok),
rev=st.rev,
name=st.name,
obj_id=ov.id if ov is not None else None,
obj_version=ov.version if ov is not None else None,
file_id=fi.id if fi is not None else None,
file_version=fi.version if fi is not None else None,
file_size=fi.size if fi is not None else None,
base=st.base,
draft_rev=st.draft_rev,
draft_where=st.draft_where or "",
in_drafting=bool(st.in_drafting),
note=(st.note or "")[:NOTE_MAX],
)
class MFilesLookup:
"""M-Files through `mfileskit.drawings.DrawingLookup`. Read-only."""
def __init__(self, vault: Vault) -> None:
from mfileskit.drawings import DrawingLookup
self.vault = vault
self._lookup = DrawingLookup(vault) # reads the vault schema; raises MFilesError when the vault is down
self._seen: dict[str, DrawingStatus] = {}
def status(self, dwg: str) -> Found:
from mfileskit.errors import MFilesError
dwg = (dwg or "").strip()
try:
st = self._lookup.status(dwg)
except MFilesError as e: # status() catches these itself; this is a second net
return failed(dwg, f"M-Files lookup failed: {e}")
self._seen[dwg] = st
return from_status(st)
def download(self, found: Found, dest: Path) -> int:
from mfileskit.errors import MFilesError
st = self._seen.get(found.dwg)
if st is None or st.obj_ver is None or st.file is None:
raise DrawingFetchError(f"{found.dwg}: no released file to download")
try:
return int(self._lookup.download(st, dest))
except MFilesError as e:
raise DrawingFetchError(f"{found.dwg}: download failed: {e}"[:NOTE_MAX]) from None
def describe(self) -> str:
url = getattr(getattr(self.vault, "settings", None), "url", "") or ""
return f"M-Files {url}".strip()
# ---------------------------------------------------------------- one vault per process
_LOCK = threading.Lock()
_CACHE: dict[str, Any] = {}
def _cache_for(cache: dict[str, Any] | None) -> tuple[dict[str, Any], str]:
if cache is not None:
return cache, VAULT_KEY
return _CACHE, f"{VAULT_KEY}:{os.environ.get('MFILES_URL', '')}"
def vault_for(settings: Settings, cache: dict[str, Any] | None = None) -> Vault:
"""The process's Vault, made once with `Vault.from_env()` (MFILES_* from the environment; the entrypoint
exports MFILES_PASSWORD from its secret file) and signed in."""
from mfileskit.vault import Vault
store, key = _cache_for(cache)
with _LOCK:
vault = store.get(key)
if vault is None:
vault = Vault.from_env().connect()
store[key] = vault
return vault
def forget_vault(cache: dict[str, Any] | None = None) -> None:
"""Drop (and close) the cached Vault, so the next run signs in again."""
store, key = _cache_for(cache)
with _LOCK:
vault = store.pop(key, None)
if vault is not None:
try:
vault.close()
except Exception as e: # closing a broken connection may fail; nothing to do
log.info("closing the M-Files vault failed: %s", type(e).__name__)
def make(settings: Settings, cache: dict[str, Any] | None = None) -> Lookup:
"""The lookup for one run. `drawing_source = fixture` reads `fixture_dir`; otherwise M-Files.
Raises when M-Files cannot be opened (the job then records a degraded run). A failed open drops the cached
Vault so the next run tries a fresh sign-in.
"""
if getattr(settings, "drawing_source", "mfiles") == "fixture":
from .fake import FixtureLookup
return FixtureLookup(Path(settings.fixture_dir))
try:
return MFilesLookup(vault_for(settings, cache))
except Exception:
forget_vault(cache)
raise
+113
View File
@@ -0,0 +1,113 @@
"""Which drawings the drawing job looks up this run, in order.
Over live packets made from VISUAL that are not filed, have a drawing number, and sit in a department in scope
(feed setting `departments`; empty = every department). One item per drawing number (trimmed), the best priority
winning:
1. the VISUAL rev changed since the last lookup (`drawing_lookups.visual_rev_seen`), or a pending feed change
order waits for a drawing (the item is the CO's target drawing and rev; at most every 15 minutes);
2. drafts still needing a drawing (`drawing_state = 'needed'`, or never looked up), unless a failed fetch set a
later `next_check_at`;
3. failed lookups whose backoff ran out (`ok = false and next_check_at <= now`);
4. rechecks due (`checked_at` older than `drawing_recheck_hours`).
A packet with a hand drawing joins only through (1) or (4) once the feed has taken the hand drawing off; while
`drawing_state = 'hand'` it is skipped.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any
BACKOFF_FIRST = timedelta(minutes=15)
BACKOFF_MAX = timedelta(hours=24)
CO_RECHECK = timedelta(minutes=15) # a change order's target drawing is looked up at most this often
QUEUE_SQL = """
WITH own AS (
SELECT p.id AS packet_id, btrim(p.drawing) AS dwg, coalesce(btrim(p.rev), '') AS visual_rev, NULL::bigint AS co_id,
CASE
WHEN d.dwg IS NOT NULL AND d.checked_at IS NOT NULL
AND d.visual_rev_seen IS DISTINCT FROM coalesce(btrim(p.rev), '') THEN 1
WHEN p.status = 'draft' AND (d.dwg IS NULL OR p.drawing_state = 'needed')
AND (d.next_check_at IS NULL OR d.next_check_at <= %(now)s) THEN 2
WHEN d.ok = false AND (d.next_check_at IS NULL OR d.next_check_at <= %(now)s) THEN 3
WHEN d.checked_at IS NULL OR d.checked_at < %(recheck_before)s THEN 4
END AS priority,
d.checked_at
FROM packets p
LEFT JOIN drawing_lookups d ON d.dwg = btrim(p.drawing)
WHERE p.removed_at IS NULL AND p.source_kind = 'visual' AND p.status <> 'filed'
AND coalesce(btrim(p.drawing), '') <> '' AND p.drawing_state <> 'hand'
AND (cardinality(%(depts)s::text[]) = 0 OR p.dept_code = ANY(%(depts)s::text[]))
),
co AS (
SELECT p.id AS packet_id, btrim(c.waiting->>'dwg') AS dwg, coalesce(btrim(c.waiting->>'rev'), '') AS visual_rev,
c.id AS co_id, 1 AS priority, d.checked_at
FROM change_orders c
JOIN packets p ON p.id = c.packet_id
LEFT JOIN drawing_lookups d ON d.dwg = btrim(c.waiting->>'dwg')
WHERE c.review = 'pending' AND c.origin IN ('visual', 'drawing') AND c.waiting IS NOT NULL
AND jsonb_typeof(c.waiting) = 'object' AND coalesce(btrim(c.waiting->>'dwg'), '') <> ''
AND p.removed_at IS NULL AND p.status NOT IN ('draft', 'filed')
AND (d.checked_at IS NULL OR d.checked_at < %(co_before)s)
AND (d.ok IS NOT false OR d.next_check_at IS NULL OR d.next_check_at <= %(now)s)
AND (cardinality(%(depts)s::text[]) = 0 OR p.dept_code = ANY(%(depts)s::text[]))
),
items AS (
SELECT * FROM own WHERE priority IS NOT NULL
UNION ALL
SELECT * FROM co
),
best AS (
SELECT DISTINCT ON (dwg) packet_id, dwg, visual_rev, priority, co_id, checked_at
FROM items
ORDER BY dwg, priority, co_id NULLS LAST, packet_id
)
SELECT packet_id, dwg, visual_rev, priority, co_id
FROM best
ORDER BY priority, checked_at NULLS FIRST, dwg
"""
@dataclass(frozen=True, slots=True)
class QueueItem:
packet_id: int
dwg: str
visual_rev: str
priority: int
co_id: int | None
def backoff(fail_count: int) -> timedelta:
"""15 minutes x 2^(fail_count - 1), at most 24 hours. fail_count counts this failure (1 = first)."""
n = max(0, int(fail_count) - 1)
if n >= 10:
return BACKOFF_MAX
return min(BACKOFF_FIRST * (2**n), BACKOFF_MAX)
def params(feed_settings: Any, now: datetime) -> dict[str, Any]:
hours = int(getattr(feed_settings, "drawing_recheck_hours", 4))
depts = [str(d) for d in (getattr(feed_settings, "departments", None) or [])]
return {
"now": now,
"recheck_before": now - timedelta(hours=hours),
"co_before": now - CO_RECHECK,
"depts": depts,
}
def queue(conn: Any, feed_settings: Any, now: datetime, *, limit: int | None = None) -> list[QueueItem]:
"""The ordered lookup list. `limit` cuts it (the job passes the budget)."""
sql = QUEUE_SQL
p = params(feed_settings, now)
if limit is not None:
sql += " LIMIT %(limit)s"
p["limit"] = max(0, int(limit))
rows = conn.execute(sql, p).fetchall()
return [
QueueItem(int(r[0]), str(r[1]), str(r[2] or ""), int(r[3]), None if r[4] is None else int(r[4])) for r in rows
]