2026-10-16 14:45:38 -06:00
|
|
|
"""The drawing-lookup job: look drawings up in M-Files (read-only), copy released PDFs into assets, and attach,
|
|
|
|
|
flag, stage or open a change order on the packets that use them.
|
|
|
|
|
|
|
|
|
|
Per run: the queue (drawings/queue.py) cut to `drawing_budget`; per drawing one `Lookup.status`, a fetch when a
|
|
|
|
|
released file exists (drawings/fetch.py downloads only when its M-Files identity changed), the `drawing_lookups`
|
|
|
|
|
row, then `decide` for every live packet that uses the drawing (its own drawing, or the target of its pending feed
|
2026-10-19 13:36:42 -06:00
|
|
|
change order), applied through `feed.writes.system_write`. A busy packet is left for the next run. On a draft
|
|
|
|
|
flagged rev_mismatch, the rev VISUAL calls is looked up too (`Lookup.revision`) and copied into assets when M-Files
|
|
|
|
|
still holds it (Retired, say), so Clerical can choose it or the Effective rev.
|
2026-10-16 14:45:38 -06:00
|
|
|
|
|
|
|
|
Never writes to M-Files.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
from psycopg.rows import dict_row
|
|
|
|
|
from psycopg.types.json import Jsonb
|
|
|
|
|
|
|
|
|
|
from ... import audit, policy
|
|
|
|
|
from ...domain import pipeline
|
|
|
|
|
from ...drawings import DRAWING_FLAGS, decide, queue
|
|
|
|
|
from ...drawings import lookup as lookups
|
|
|
|
|
from ...drawings.fetch import FetchError, FetchResult, fetch
|
|
|
|
|
from ...drawings.lookup import Found, Lookup
|
|
|
|
|
from ...feed import changes, writes
|
|
|
|
|
from . import Counts, JobContext, JobSpec
|
|
|
|
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
A = policy.Action
|
|
|
|
|
D = decide
|
|
|
|
|
|
|
|
|
|
FEED_ORIGINS = ("visual", "drawing")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _Stale(Exception):
|
|
|
|
|
"""The packet changed between the read and the lock; the next run decides again."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def new_counts() -> Counts:
|
|
|
|
|
return {
|
|
|
|
|
"checked": 0,
|
|
|
|
|
"fetched": 0,
|
|
|
|
|
"unchanged": 0,
|
|
|
|
|
"attached": 0,
|
|
|
|
|
"flagged": {},
|
|
|
|
|
"lookup_failed": 0,
|
|
|
|
|
"cos_opened": 0,
|
|
|
|
|
"cos_reaimed": 0,
|
|
|
|
|
"staged": 0,
|
|
|
|
|
"budget_left": 0,
|
|
|
|
|
"fetch_failed": 0,
|
|
|
|
|
"busy": 0,
|
|
|
|
|
"refused": 0,
|
|
|
|
|
"no_file": 0,
|
2026-10-19 13:36:42 -06:00
|
|
|
"called_fetched": 0,
|
2026-10-16 14:45:38 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- the run
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def run(ctx: JobContext) -> Counts:
|
|
|
|
|
counts = new_counts()
|
|
|
|
|
fs = ctx.feed_settings
|
|
|
|
|
budget = int(getattr(fs, "drawing_budget", 60))
|
|
|
|
|
try:
|
|
|
|
|
lk = lookups.make(ctx.settings, ctx.stack.cache)
|
|
|
|
|
except Exception as e: # any failure to open M-Files skips the run
|
|
|
|
|
ctx.error(f"M-Files could not be opened: {type(e).__name__}: {e}")
|
|
|
|
|
counts["reason"] = "mfiles unavailable"
|
|
|
|
|
counts["budget_left"] = budget
|
|
|
|
|
return counts
|
|
|
|
|
now = datetime.now(UTC)
|
|
|
|
|
items = queue.queue(ctx.conn, fs, now, limit=budget)
|
|
|
|
|
counts["budget_left"] = max(0, budget - len(items))
|
|
|
|
|
counts["source"] = lk.describe()
|
|
|
|
|
for item in items:
|
|
|
|
|
try:
|
|
|
|
|
_drawing(ctx, lk, item, counts, now)
|
|
|
|
|
except Exception as e: # one bad drawing must not stop the others
|
|
|
|
|
log.exception("drawing %s failed", item.dwg)
|
|
|
|
|
ctx.error(f"drawing {item.dwg}: {type(e).__name__}: {e}")
|
|
|
|
|
return counts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _drawing(ctx: JobContext, lk: Lookup, item: queue.QueueItem, counts: Counts, now: datetime) -> None:
|
|
|
|
|
conn = ctx.conn
|
|
|
|
|
try:
|
|
|
|
|
found = lk.status(item.dwg)
|
|
|
|
|
except Exception as e: # a lookup error is a failed lookup, never a crash
|
|
|
|
|
found = lookups.failed(item.dwg, f"M-Files lookup failed: {type(e).__name__}: {e}")
|
|
|
|
|
counts["checked"] += 1
|
|
|
|
|
fetched: FetchResult | None = None
|
|
|
|
|
fetch_failed = False
|
|
|
|
|
if found.ok and found.rev is not None:
|
|
|
|
|
try:
|
|
|
|
|
fetched = fetch(conn, ctx.settings, lk, found, dwg=item.dwg)
|
|
|
|
|
except FetchError as e:
|
|
|
|
|
fetch_failed = True
|
|
|
|
|
counts["fetch_failed"] += 1
|
|
|
|
|
ctx.error(str(e))
|
|
|
|
|
else:
|
|
|
|
|
if fetched.downloaded:
|
|
|
|
|
counts["fetched"] += 1
|
|
|
|
|
audit.append(
|
|
|
|
|
conn,
|
|
|
|
|
action="drawing.fetched",
|
|
|
|
|
role_snapshot="system",
|
|
|
|
|
subject_type="asset",
|
|
|
|
|
subject_id=str(fetched.asset_id),
|
|
|
|
|
detail={
|
|
|
|
|
"dwg": item.dwg,
|
|
|
|
|
"rev": found.rev,
|
|
|
|
|
"sha256": fetched.sha256.hex(),
|
|
|
|
|
"reused": fetched.reused,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if not found.ok:
|
|
|
|
|
counts["lookup_failed"] += 1
|
|
|
|
|
ctx.error(f"drawing {item.dwg}: {D.failed_note(found)}")
|
|
|
|
|
vrev_seen = item.visual_rev if item.co_id is None else own_rev(conn, item.dwg)
|
|
|
|
|
save_lookup(conn, item, found, fetched, now, fetch_failed=fetch_failed, visual_rev_seen=vrev_seen)
|
|
|
|
|
scope = [str(d) for d in (getattr(ctx.feed_settings, "departments", None) or [])]
|
2026-10-19 13:36:42 -06:00
|
|
|
called: dict[int, dict[str, Any] | None] = {} # VISUAL rev -> what M-Files holds at that rev, this drawing
|
2026-10-16 14:45:38 -06:00
|
|
|
for pid, relations in sharing(conn, item.dwg, scope):
|
2026-10-19 13:36:42 -06:00
|
|
|
_packet(ctx, lk, item.dwg, found, fetched, pid, relations, counts, called)
|
2026-10-16 14:45:38 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- drawing_lookups
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def save_lookup(
|
|
|
|
|
conn: Any,
|
|
|
|
|
item: queue.QueueItem,
|
|
|
|
|
found: Found,
|
|
|
|
|
fetched: FetchResult | None,
|
|
|
|
|
now: datetime,
|
|
|
|
|
*,
|
|
|
|
|
fetch_failed: bool,
|
|
|
|
|
visual_rev_seen: str | None,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Upsert the drawing_lookups row for this lookup.
|
|
|
|
|
|
|
|
|
|
A failed lookup keeps everything known before (rev, file, asset) and backs off. A good one stores what M-Files
|
|
|
|
|
has; the file identity and the asset are stored together, and cleared when there is no released file or the
|
|
|
|
|
copy failed (so the next run downloads again).
|
|
|
|
|
"""
|
|
|
|
|
with conn.transaction():
|
|
|
|
|
prev = conn.execute("SELECT fail_count FROM drawing_lookups WHERE dwg = %s FOR UPDATE", (item.dwg,)).fetchone()
|
|
|
|
|
if not found.ok:
|
|
|
|
|
fails = (int(prev[0]) if prev else 0) + 1
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT INTO drawing_lookups (dwg, checked_at, ok, note, fail_count, failing_since, next_check_at,"
|
|
|
|
|
" visual_rev_seen) VALUES (%(dwg)s, %(now)s, false, %(note)s, %(fails)s, %(now)s, %(next)s, %(vrev)s)"
|
|
|
|
|
" ON CONFLICT (dwg) DO UPDATE SET checked_at = EXCLUDED.checked_at, ok = false, note = EXCLUDED.note,"
|
|
|
|
|
" fail_count = EXCLUDED.fail_count,"
|
|
|
|
|
" failing_since = coalesce(drawing_lookups.failing_since, EXCLUDED.failing_since),"
|
|
|
|
|
" next_check_at = EXCLUDED.next_check_at, visual_rev_seen = EXCLUDED.visual_rev_seen",
|
|
|
|
|
{
|
|
|
|
|
"dwg": item.dwg,
|
|
|
|
|
"now": now,
|
|
|
|
|
"note": D.failed_note(found),
|
|
|
|
|
"fails": fails,
|
|
|
|
|
"next": now + queue.backoff(fails),
|
|
|
|
|
"vrev": visual_rev_seen,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
has_file = fetched is not None
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT INTO drawing_lookups (dwg, checked_at, ok, rev, name, obj_id, obj_version, file_id, file_version,"
|
|
|
|
|
" file_size, base, draft_rev, draft_where, in_drafting, note, fail_count, failing_since, asset_id, sha256,"
|
|
|
|
|
" visual_rev_seen, next_check_at) VALUES (%(dwg)s, %(now)s, true, %(rev)s, %(name)s, %(obj_id)s,"
|
|
|
|
|
" %(obj_version)s, %(file_id)s, %(file_version)s, %(file_size)s, %(base)s, %(draft_rev)s,"
|
|
|
|
|
" %(draft_where)s, %(in_drafting)s, %(note)s, 0, NULL, %(asset_id)s, %(sha)s, %(vrev)s, %(next)s)"
|
|
|
|
|
" ON CONFLICT (dwg) DO UPDATE SET checked_at = EXCLUDED.checked_at, ok = true, rev = EXCLUDED.rev,"
|
|
|
|
|
" name = EXCLUDED.name, obj_id = EXCLUDED.obj_id, obj_version = EXCLUDED.obj_version,"
|
|
|
|
|
" file_id = EXCLUDED.file_id, file_version = EXCLUDED.file_version, file_size = EXCLUDED.file_size,"
|
|
|
|
|
" base = EXCLUDED.base, draft_rev = EXCLUDED.draft_rev, draft_where = EXCLUDED.draft_where,"
|
|
|
|
|
" in_drafting = EXCLUDED.in_drafting, note = EXCLUDED.note, fail_count = 0, failing_since = NULL,"
|
|
|
|
|
" asset_id = EXCLUDED.asset_id, sha256 = EXCLUDED.sha256,"
|
|
|
|
|
" visual_rev_seen = coalesce(EXCLUDED.visual_rev_seen, drawing_lookups.visual_rev_seen),"
|
|
|
|
|
" next_check_at = EXCLUDED.next_check_at",
|
|
|
|
|
{
|
|
|
|
|
"dwg": item.dwg,
|
|
|
|
|
"now": now,
|
|
|
|
|
"rev": found.rev,
|
|
|
|
|
"name": found.name,
|
|
|
|
|
"obj_id": found.obj_id if has_file else None,
|
|
|
|
|
"obj_version": found.obj_version if has_file else None,
|
|
|
|
|
"file_id": found.file_id if has_file else None,
|
|
|
|
|
"file_version": found.file_version if has_file else None,
|
|
|
|
|
"file_size": found.file_size,
|
|
|
|
|
"base": found.base,
|
|
|
|
|
"draft_rev": found.draft_rev,
|
|
|
|
|
"draft_where": found.draft_where or None,
|
|
|
|
|
"in_drafting": found.in_drafting,
|
|
|
|
|
"note": found.note or None,
|
|
|
|
|
"asset_id": fetched.asset_id if fetched else None,
|
|
|
|
|
"sha": fetched.sha256 if fetched else None,
|
|
|
|
|
"vrev": visual_rev_seen,
|
|
|
|
|
"next": now + queue.backoff(1) if fetch_failed else None,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 13:36:42 -06:00
|
|
|
def save_revision(conn: Any, dwg: str, rf: Found, fetched: FetchResult, now: datetime) -> None:
|
|
|
|
|
"""Upsert the drawing_revisions row for a rev `Lookup.revision` found and the copy of its file."""
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT INTO drawing_revisions (dwg, rev, checked_at, controlled, state, name, obj_id, obj_version, file_id,"
|
|
|
|
|
" file_version, file_size, base, asset_id, sha256) VALUES (%(dwg)s, %(rev)s, %(now)s, %(controlled)s,"
|
|
|
|
|
" %(state)s, %(name)s, %(obj_id)s, %(obj_version)s, %(file_id)s, %(file_version)s, %(file_size)s, %(base)s,"
|
|
|
|
|
" %(asset_id)s, %(sha)s) ON CONFLICT (dwg, rev) DO UPDATE SET checked_at = EXCLUDED.checked_at,"
|
|
|
|
|
" controlled = EXCLUDED.controlled, state = EXCLUDED.state, name = EXCLUDED.name, obj_id = EXCLUDED.obj_id,"
|
|
|
|
|
" obj_version = EXCLUDED.obj_version, file_id = EXCLUDED.file_id, file_version = EXCLUDED.file_version,"
|
|
|
|
|
" file_size = EXCLUDED.file_size, base = EXCLUDED.base, asset_id = EXCLUDED.asset_id, sha256 = EXCLUDED.sha256",
|
|
|
|
|
{
|
|
|
|
|
"dwg": dwg,
|
|
|
|
|
"rev": rf.rev,
|
|
|
|
|
"now": now,
|
|
|
|
|
"controlled": rf.controlled,
|
|
|
|
|
"state": rf.state or None,
|
|
|
|
|
"name": fetched.name or rf.name,
|
|
|
|
|
"obj_id": rf.obj_id,
|
|
|
|
|
"obj_version": rf.obj_version,
|
|
|
|
|
"file_id": rf.file_id,
|
|
|
|
|
"file_version": rf.file_version,
|
|
|
|
|
"file_size": rf.file_size,
|
|
|
|
|
"base": rf.base,
|
|
|
|
|
"asset_id": fetched.asset_id,
|
|
|
|
|
"sha": fetched.sha256,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def called_rev(
|
|
|
|
|
ctx: JobContext, lk: Lookup, dwg: str, visual_rev: str, memo: dict[int, dict[str, Any] | None], counts: Counts
|
|
|
|
|
) -> dict[str, Any] | None:
|
|
|
|
|
"""The rev VISUAL calls, as M-Files holds it: {rev, controlled, state, name, asset_id, sha256, pages}, or
|
|
|
|
|
{rev, missing: true} when M-Files has no document at that rev. None when it could not be read this run (the
|
|
|
|
|
packet keeps what it had) or the VISUAL rev is not a number."""
|
|
|
|
|
v = D.as_int(visual_rev)
|
|
|
|
|
if v is None:
|
|
|
|
|
return None
|
|
|
|
|
if v in memo:
|
|
|
|
|
return memo[v]
|
|
|
|
|
memo[v] = None
|
|
|
|
|
conn = ctx.conn
|
|
|
|
|
try:
|
|
|
|
|
rf = lk.revision(dwg, v)
|
|
|
|
|
except LookupError as e:
|
|
|
|
|
ctx.error(f"drawing {dwg}: rev {v}: {e}")
|
|
|
|
|
return None
|
|
|
|
|
if rf is None:
|
|
|
|
|
memo[v] = {"rev": str(v), "missing": True}
|
|
|
|
|
return memo[v]
|
|
|
|
|
try:
|
|
|
|
|
fetched = fetch(conn, ctx.settings, lk, rf, dwg=dwg)
|
|
|
|
|
except FetchError as e:
|
|
|
|
|
counts["fetch_failed"] += 1
|
|
|
|
|
ctx.error(str(e))
|
|
|
|
|
return None
|
|
|
|
|
with conn.transaction():
|
|
|
|
|
save_revision(conn, dwg, rf, fetched, datetime.now(UTC))
|
|
|
|
|
if fetched.downloaded:
|
|
|
|
|
counts["called_fetched"] += 1
|
|
|
|
|
audit.append(
|
|
|
|
|
conn,
|
|
|
|
|
action="drawing.fetched",
|
|
|
|
|
role_snapshot="system",
|
|
|
|
|
subject_type="asset",
|
|
|
|
|
subject_id=str(fetched.asset_id),
|
|
|
|
|
detail={
|
|
|
|
|
"dwg": dwg,
|
|
|
|
|
"rev": v,
|
|
|
|
|
"controlled": rf.controlled,
|
|
|
|
|
"sha256": fetched.sha256.hex(),
|
|
|
|
|
"reused": fetched.reused,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
memo[v] = {
|
|
|
|
|
"rev": str(v),
|
|
|
|
|
"controlled": rf.controlled,
|
|
|
|
|
"state": rf.state or None,
|
|
|
|
|
"name": fetched.name or rf.name,
|
|
|
|
|
"asset_id": str(fetched.asset_id),
|
|
|
|
|
"sha256": fetched.sha256.hex(),
|
|
|
|
|
"pages": fetched.pages,
|
|
|
|
|
}
|
|
|
|
|
return memo[v]
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 14:45:38 -06:00
|
|
|
# ---------------------------------------------------------------- packets
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def own_rev(conn: Any, dwg: str) -> str | None:
|
|
|
|
|
"""The rev a live VISUAL packet with this drawing carries (for visual_rev_seen when the item came from a CO)."""
|
|
|
|
|
row = conn.execute(
|
|
|
|
|
"SELECT coalesce(btrim(rev), '') FROM packets WHERE removed_at IS NULL AND source_kind = 'visual'"
|
|
|
|
|
" AND status <> 'filed' AND btrim(drawing) = %s ORDER BY id LIMIT 1",
|
|
|
|
|
(dwg,),
|
|
|
|
|
).fetchone()
|
|
|
|
|
return None if row is None else str(row[0])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
SHARING_SQL = """
|
|
|
|
|
SELECT p.id, 'own' AS rel FROM packets p
|
|
|
|
|
WHERE p.removed_at IS NULL AND p.source_kind = 'visual' AND p.status <> 'filed' AND btrim(p.drawing) = %(dwg)s
|
|
|
|
|
AND (cardinality(%(depts)s::text[]) = 0 OR p.dept_code = ANY(%(depts)s::text[]))
|
|
|
|
|
UNION
|
|
|
|
|
SELECT p.id, 'co' AS rel FROM change_orders c JOIN packets p ON p.id = c.packet_id
|
|
|
|
|
WHERE c.review = 'pending' AND c.origin IN ('visual', 'drawing') AND jsonb_typeof(c.waiting) = 'object'
|
|
|
|
|
AND btrim(c.waiting->>'dwg') = %(dwg)s AND p.removed_at IS NULL AND p.status NOT IN ('draft', 'filed')
|
|
|
|
|
AND (cardinality(%(depts)s::text[]) = 0 OR p.dept_code = ANY(%(depts)s::text[]))
|
|
|
|
|
ORDER BY 1
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def sharing(conn: Any, dwg: str, depts: list[str]) -> list[tuple[int, set[str]]]:
|
|
|
|
|
"""Live packets that use the drawing: as their own drawing ('own') or as a pending feed CO's target ('co')."""
|
|
|
|
|
out: dict[int, set[str]] = {}
|
|
|
|
|
for pid, rel in conn.execute(SHARING_SQL, {"dwg": dwg, "depts": depts}).fetchall():
|
|
|
|
|
out.setdefault(int(pid), set()).add(str(rel))
|
|
|
|
|
return sorted(out.items())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class PacketFacts:
|
|
|
|
|
row: dict[str, Any]
|
|
|
|
|
visual: dict[str, Any]
|
|
|
|
|
attached_rev: str | None
|
|
|
|
|
attached_shas: set[bytes]
|
|
|
|
|
co: dict[str, Any] | None
|
|
|
|
|
rejected_sha: bytes | None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sha(value: Any) -> bytes | None:
|
|
|
|
|
if not isinstance(value, str) or not value:
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
return bytes.fromhex(value)
|
|
|
|
|
except ValueError:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def facts(conn: Any, pid: int) -> PacketFacts | None:
|
|
|
|
|
with conn.cursor(row_factory=dict_row) as cur:
|
|
|
|
|
row = cur.execute(
|
|
|
|
|
"SELECT id, status::text AS status, drawing, rev, drawing_state::text AS drawing_state, flags, visual,"
|
|
|
|
|
" version FROM packets WHERE id = %s AND removed_at IS NULL",
|
|
|
|
|
(pid,),
|
|
|
|
|
).fetchone()
|
|
|
|
|
if row is None:
|
|
|
|
|
return None
|
|
|
|
|
pages = cur.execute(
|
|
|
|
|
"SELECT pg.rev, a.sha256 FROM packet_pages pg LEFT JOIN assets a ON a.id = pg.asset_id"
|
|
|
|
|
" WHERE pg.packet_id = %s AND pg.type = 'drawing' AND pg.void_at IS NULL ORDER BY pg.n",
|
|
|
|
|
(pid,),
|
|
|
|
|
).fetchall()
|
|
|
|
|
co = cur.execute(
|
|
|
|
|
"SELECT id, co_no, origin::text AS origin, waiting, staged FROM change_orders"
|
|
|
|
|
" WHERE packet_id = %s AND review = 'pending' AND origin IN ('visual', 'drawing') ORDER BY id DESC LIMIT 1",
|
|
|
|
|
(pid,),
|
|
|
|
|
).fetchone()
|
|
|
|
|
rej = cur.execute(
|
|
|
|
|
"SELECT staged FROM change_orders WHERE packet_id = %s AND origin = 'drawing' AND review = 'rejected'"
|
|
|
|
|
" ORDER BY id DESC LIMIT 1",
|
|
|
|
|
(pid,),
|
|
|
|
|
).fetchone()
|
|
|
|
|
visual = row["visual"] if isinstance(row["visual"], dict) else {}
|
|
|
|
|
attached_rev = None
|
|
|
|
|
if pages:
|
|
|
|
|
attached_rev = str(pages[0]["rev"] or "").strip()
|
|
|
|
|
shas = {bytes(p["sha256"]) for p in pages if p["sha256"] is not None}
|
|
|
|
|
rejected = None
|
|
|
|
|
if rej is not None and isinstance(rej["staged"], dict):
|
|
|
|
|
rejected = _sha(rej["staged"].get("sha256"))
|
|
|
|
|
return PacketFacts(dict(row), dict(visual), attached_rev, shas, dict(co) if co else None, rejected)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _dwg_rev(value: Any) -> tuple[str, str] | None:
|
|
|
|
|
if isinstance(value, dict) and value.get("dwg") and value.get("rev") is not None:
|
|
|
|
|
return (str(value["dwg"]).strip(), str(value["rev"]).strip())
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def target_for(dwg: str, pf: PacketFacts, *, own: bool) -> D.Target:
|
|
|
|
|
row, visual, co = pf.row, pf.visual, pf.co
|
|
|
|
|
waiting = co.get("waiting") if co else None
|
|
|
|
|
staged = co.get("staged") if co else None
|
|
|
|
|
co_waiting = _dwg_rev(waiting)
|
|
|
|
|
co_staged = _dwg_rev(staged)
|
|
|
|
|
packet_rev = str(row["rev"] or "").strip()
|
2026-10-19 13:36:42 -06:00
|
|
|
rev_ok = kept_latest = None
|
2026-10-16 14:45:38 -06:00
|
|
|
ro = visual.get("rev_ok")
|
|
|
|
|
if isinstance(ro, dict) and ro.get("rev") is not None and str(ro.get("vis_rev") or "").strip() == packet_rev:
|
2026-10-19 13:36:42 -06:00
|
|
|
if ro.get("use") == "called":
|
|
|
|
|
kept_latest = None if ro.get("latest") is None else str(ro["latest"]).strip()
|
|
|
|
|
else:
|
|
|
|
|
rev_ok = str(ro["rev"]).strip()
|
2026-10-16 14:45:38 -06:00
|
|
|
visual_rev = packet_rev if own or co_waiting is None else co_waiting[1]
|
|
|
|
|
return D.Target(
|
|
|
|
|
dwg=dwg,
|
|
|
|
|
visual_rev=visual_rev,
|
|
|
|
|
attached_rev=pf.attached_rev,
|
|
|
|
|
attached_sha=next(iter(sorted(pf.attached_shas)), None),
|
|
|
|
|
status=str(row["status"]),
|
|
|
|
|
drawing_state=str(row["drawing_state"]),
|
|
|
|
|
has_hand_drawing=row["drawing_state"] == "hand" or bool(visual.get("manual_dwg")),
|
|
|
|
|
rev_ok=rev_ok,
|
|
|
|
|
co_waiting=co_waiting,
|
|
|
|
|
co_staged=co_staged,
|
|
|
|
|
co_pending=co is not None,
|
|
|
|
|
co_staged_sha=_sha(staged.get("sha256")) if isinstance(staged, dict) else None,
|
|
|
|
|
rejected_sha=pf.rejected_sha,
|
2026-10-19 13:36:42 -06:00
|
|
|
kept_latest=kept_latest,
|
2026-10-16 14:45:38 -06:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def appr_of(t: D.Target, f: Found, now: datetime) -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"ok": f.ok,
|
|
|
|
|
"rev": f.rev,
|
|
|
|
|
"vrev": t.visual_rev,
|
|
|
|
|
"name": f.name,
|
|
|
|
|
"note": f.note or "",
|
|
|
|
|
"base": f.base,
|
|
|
|
|
"draft_rev": f.draft_rev,
|
|
|
|
|
"draft_where": f.draft_where,
|
|
|
|
|
"in_drafting": f.in_drafting,
|
|
|
|
|
"at": now.isoformat(),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _same_appr(a: Any, b: dict[str, Any]) -> bool:
|
|
|
|
|
if not isinstance(a, dict):
|
|
|
|
|
return False
|
|
|
|
|
return {k: v for k, v in a.items() if k != "at"} == {k: v for k, v in b.items() if k != "at"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def new_flags(flags: list[str], d: D.Decision) -> list[str]:
|
|
|
|
|
"""Every flag but the drawing ones, then the decision's flag."""
|
|
|
|
|
out = [f for f in flags if f not in DRAWING_FLAGS]
|
|
|
|
|
if d.flag and d.flag not in out:
|
|
|
|
|
out.append(d.flag)
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _packet(
|
|
|
|
|
ctx: JobContext,
|
2026-10-19 13:36:42 -06:00
|
|
|
lk: Lookup,
|
2026-10-16 14:45:38 -06:00
|
|
|
dwg: str,
|
|
|
|
|
found: Found,
|
|
|
|
|
fetched: FetchResult | None,
|
|
|
|
|
pid: int,
|
|
|
|
|
relations: set[str],
|
|
|
|
|
counts: Counts,
|
2026-10-19 13:36:42 -06:00
|
|
|
called_memo: dict[int, dict[str, Any] | None],
|
2026-10-16 14:45:38 -06:00
|
|
|
) -> None:
|
|
|
|
|
conn = ctx.conn
|
|
|
|
|
pf = facts(conn, pid)
|
|
|
|
|
if pf is None:
|
|
|
|
|
return
|
|
|
|
|
own = "own" in relations
|
|
|
|
|
t = target_for(dwg, pf, own=own)
|
|
|
|
|
same_file = fetched is not None and fetched.sha256 in pf.attached_shas
|
|
|
|
|
d = D.decide(t, found, same_file=same_file, fetched_sha=fetched.sha256 if fetched else None)
|
|
|
|
|
if not own and d.kind != D.STAGE_FOR_CO:
|
|
|
|
|
return # only the CO's target drawing: nothing to do until it can be staged
|
|
|
|
|
if d.kind in (D.ATTACH, D.OPEN_CO, D.STAGE_FOR_CO) and fetched is None:
|
|
|
|
|
counts["no_file"] += 1 # the copy failed this run; try again next run
|
|
|
|
|
return
|
|
|
|
|
now = datetime.now(UTC)
|
|
|
|
|
row, visual = pf.row, pf.visual
|
|
|
|
|
flags = list(row["flags"] or [])
|
|
|
|
|
flags_after = new_flags(flags, d) if own else flags
|
|
|
|
|
patch: dict[str, Any] = {}
|
|
|
|
|
if own:
|
|
|
|
|
appr = appr_of(t, found, now)
|
|
|
|
|
if not _same_appr(visual.get("appr"), appr):
|
|
|
|
|
patch["appr"] = appr
|
|
|
|
|
if found.ok:
|
|
|
|
|
confirmed = D.rev_confirmed(t, found)
|
|
|
|
|
if bool(visual.get("rev_confirmed")) != confirmed:
|
|
|
|
|
patch["rev_confirmed"] = confirmed
|
2026-10-19 13:36:42 -06:00
|
|
|
if d.kind == D.REV_MISMATCH and row["status"] == "draft":
|
|
|
|
|
called = called_rev(ctx, lk, dwg, t.visual_rev, called_memo, counts)
|
|
|
|
|
if called is not None and visual.get("called") != called:
|
|
|
|
|
patch["called"] = called
|
|
|
|
|
elif visual.get("called") is not None:
|
|
|
|
|
patch["called"] = None
|
2026-10-16 14:45:38 -06:00
|
|
|
state_after = d.drawing_state if own else str(row["drawing_state"])
|
|
|
|
|
material = (
|
|
|
|
|
d.kind in (D.ATTACH, D.OPEN_CO, D.STAGE_FOR_CO)
|
|
|
|
|
or flags_after != flags
|
|
|
|
|
or state_after != row["drawing_state"]
|
|
|
|
|
or bool(patch)
|
|
|
|
|
)
|
|
|
|
|
if not material:
|
|
|
|
|
counts["unchanged"] += 1
|
|
|
|
|
return
|
|
|
|
|
action = A.VIS_UPDATE_DRAFT if row["status"] == "draft" else A.VIS_OPEN_CO
|
|
|
|
|
label, note = "Drawing status from M-Files", d.note or None
|
2026-10-19 13:36:42 -06:00
|
|
|
called_now = patch.get("called") or {}
|
|
|
|
|
if note and called_now.get("controlled"):
|
|
|
|
|
note = f"{note}; rev {called_now['rev']} is {called_now['controlled']} in M-Files"
|
|
|
|
|
elif note and called_now.get("missing"):
|
|
|
|
|
note = f"{note}; rev {called_now['rev']} is not in M-Files"
|
2026-10-16 14:45:38 -06:00
|
|
|
if d.kind == D.ATTACH and fetched is not None:
|
|
|
|
|
label = "Drawing attached from M-Files"
|
|
|
|
|
note = "; ".join(x for x in (fetched.name or found.name or "", d.note) if x) or None
|
|
|
|
|
detail = {"dwg": dwg, "rev": found.rev, "kind": d.kind, "asset_id": str(fetched.asset_id) if fetched else None}
|
|
|
|
|
try:
|
|
|
|
|
with writes.system_write(conn, pid, action, label=label, note=note, detail=detail) as sw:
|
|
|
|
|
if int(sw.packet["version"]) != int(row["version"]):
|
|
|
|
|
raise _Stale
|
|
|
|
|
if d.kind == D.ATTACH and fetched is not None:
|
|
|
|
|
_attach(sw, pid, dwg, found, fetched, d, flags_after, patch, now)
|
|
|
|
|
counts["attached"] += 1
|
|
|
|
|
else:
|
|
|
|
|
_status(sw, pid, state_after, flags_after, patch)
|
|
|
|
|
if d.flag:
|
|
|
|
|
fl = counts["flagged"]
|
|
|
|
|
fl[d.kind] = fl.get(d.kind, 0) + 1
|
|
|
|
|
if d.kind == D.OPEN_CO and fetched is not None:
|
|
|
|
|
_open_co(sw, t, dwg, found, fetched, d, counts)
|
|
|
|
|
elif d.kind == D.STAGE_FOR_CO and fetched is not None and pf.co is not None:
|
|
|
|
|
_stage(sw, pf.co, dwg, found, fetched)
|
|
|
|
|
counts["staged"] += 1
|
|
|
|
|
elif d.kind == D.UNCHANGED:
|
|
|
|
|
counts["unchanged"] += 1
|
|
|
|
|
except (_Stale, writes.Busy, writes.Missing):
|
|
|
|
|
counts["busy"] += 1
|
|
|
|
|
except writes.Refused as e:
|
|
|
|
|
counts["refused"] += 1
|
|
|
|
|
ctx.error(f"packet {pid}: {d.kind} refused: {e}", degrade=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _staged(dwg: str, found: Found, fetched: FetchResult) -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"asset_id": str(fetched.asset_id),
|
|
|
|
|
"name": fetched.name or found.name,
|
|
|
|
|
"rev": str(found.rev),
|
|
|
|
|
"dwg": dwg,
|
|
|
|
|
"sha256": fetched.sha256.hex(),
|
|
|
|
|
"pages": fetched.pages,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _attach(
|
|
|
|
|
sw: Any,
|
|
|
|
|
pid: int,
|
|
|
|
|
dwg: str,
|
|
|
|
|
found: Found,
|
|
|
|
|
fetched: FetchResult,
|
|
|
|
|
d: D.Decision,
|
|
|
|
|
flags: list[str],
|
|
|
|
|
patch: dict[str, Any],
|
|
|
|
|
now: datetime,
|
|
|
|
|
) -> None:
|
|
|
|
|
conn = sw.conn
|
|
|
|
|
pages = max(1, int(fetched.pages))
|
|
|
|
|
conn.execute("DELETE FROM packet_pages WHERE packet_id = %s AND type = 'drawing'", (pid,))
|
|
|
|
|
n0 = pipeline.next_counter(conn, pid, "page", pages)
|
|
|
|
|
rev = str(found.rev)
|
|
|
|
|
with conn.cursor() as cur:
|
|
|
|
|
cur.executemany(
|
|
|
|
|
"INSERT INTO packet_pages (packet_id, n, type, asset_id, pn, dwg, rev) VALUES (%s, %s, 'drawing', %s, %s,"
|
|
|
|
|
" %s, %s)",
|
|
|
|
|
[(pid, n0 + i, fetched.asset_id, i + 1, dwg, rev) for i in range(pages)],
|
|
|
|
|
)
|
|
|
|
|
vault = {
|
|
|
|
|
"asset_id": str(fetched.asset_id),
|
|
|
|
|
"name": fetched.name or found.name,
|
|
|
|
|
"rev": found.rev,
|
|
|
|
|
"note": d.note,
|
|
|
|
|
"at": now.isoformat(),
|
|
|
|
|
}
|
|
|
|
|
merged = {**patch, "rev_confirmed": True, "vault": vault}
|
|
|
|
|
conn.execute(
|
|
|
|
|
"UPDATE packets SET drawing_state = 'attached', flags = %s, visual = (visual - 'manual_dwg') || %s"
|
|
|
|
|
" WHERE id = %s",
|
|
|
|
|
(flags, Jsonb(merged), pid),
|
|
|
|
|
)
|
|
|
|
|
sw.changed("pages", "drawing_state", "flags", "visual")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _status(sw: Any, pid: int, state: str, flags: list[str], patch: dict[str, Any]) -> None:
|
|
|
|
|
sw.conn.execute(
|
|
|
|
|
"UPDATE packets SET drawing_state = %s::drawing_state, flags = %s, visual = visual || %s WHERE id = %s",
|
|
|
|
|
(state, flags, Jsonb(patch), pid),
|
|
|
|
|
)
|
|
|
|
|
sw.changed("drawing_state", "flags", "visual")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _open_co(sw: Any, t: D.Target, dwg: str, found: Found, fetched: FetchResult, d: D.Decision, counts: Counts) -> None:
|
|
|
|
|
vis = {
|
|
|
|
|
"from_dwg": dwg,
|
|
|
|
|
"to_dwg": dwg,
|
|
|
|
|
"dwg": dwg,
|
|
|
|
|
"from_rev": t.attached_rev,
|
|
|
|
|
"to_rev": str(found.rev),
|
|
|
|
|
"dwg_change": True,
|
|
|
|
|
"changes": [d.note],
|
|
|
|
|
}
|
|
|
|
|
res = changes.open_or_reaim(
|
|
|
|
|
sw,
|
|
|
|
|
origin="drawing",
|
|
|
|
|
reason=d.note,
|
|
|
|
|
vis=vis,
|
|
|
|
|
waiting=None,
|
|
|
|
|
staged=_staged(dwg, found, fetched),
|
|
|
|
|
target_fp=None,
|
|
|
|
|
minor=False,
|
|
|
|
|
)
|
|
|
|
|
# open_or_reaim writes the packet_log label itself
|
|
|
|
|
counts["cos_opened" if res.fresh else "cos_reaimed"] += 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _stage(sw: Any, co: dict[str, Any], dwg: str, found: Found, fetched: FetchResult) -> None:
|
|
|
|
|
staged = _staged(dwg, found, fetched)
|
|
|
|
|
sw.conn.execute(
|
|
|
|
|
"UPDATE change_orders SET staged = %s, waiting = NULL, row_version = row_version + 1"
|
|
|
|
|
" WHERE id = %s AND review = 'pending'",
|
|
|
|
|
(Jsonb(staged), co["id"]),
|
|
|
|
|
)
|
|
|
|
|
sw.changed("change_orders")
|
|
|
|
|
sw.log(f"Drawing for change order {co['co_no']} ready for Clerical", staged["name"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cadence(fs: Any, now: datetime) -> int | None:
|
|
|
|
|
return int(fs.drawing_interval_minutes) * 60
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
SPEC = JobSpec(name="drawing-lookup", run=run, cadence=_cadence, pausable=True)
|
|
|
|
|
|
2026-10-19 13:36:42 -06:00
|
|
|
__all__ = ["SPEC", "called_rev", "new_flags", "run", "save_lookup", "save_revision", "sharing", "target_for"]
|