drawings: on a rev mismatch, Clerical can keep the retired rev VISUAL calls (#41)
ci / server (pull_request) Failing after 17s
ci / frontend (pull_request) Successful in 42s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Skipped

When VISUAL calls a rev that M-Files has retired, the draft is still
flagged rev_mismatch, but the drawing job now also copies the rev VISUAL
calls (MFilesKit 0.1.3 DrawingLookup.revision) into drawing_revisions and
visual.called. A Clerical Supervisor picks either rev on the packet:
"Use latest rev N" as before, or "Use rev V as VISUAL calls it"
(rev-approve with use=called), with a reason and a signature.

The kept rev holds while M-Files' Effective rev stays the one chosen
against: no flag, no change order. A newer Effective rev flags the
packet again; a new VISUAL rev clears the choice.

Migration 0009 adds drawing_revisions. Needs mfileskit 0.1.3.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 13:39:14 -06:00
co-authored by Claude Opus 5.5
parent 4c0cba4da5
commit bb8eb17748
21 changed files with 547 additions and 55 deletions
+38 -8
View File
@@ -12,7 +12,7 @@ import hashlib
import uuid
from collections.abc import Mapping
from datetime import UTC, datetime
from typing import Any
from typing import Any, Literal
from fastapi import APIRouter, Depends, Request
from psycopg.types.json import Jsonb
@@ -49,6 +49,9 @@ MSG_DRAFT_ONLY = "Only a draft takes a hand upload. Once it leaves Clerical, cha
class RevApproveBody(BaseModel):
reason: str | None = Field(default=None, max_length=2000)
# "latest": M-Files' Effective rev in place of the rev VISUAL calls. "called": the rev VISUAL calls, which M-Files
# holds at another status (Retired, say).
use: Literal["latest", "called"] = "latest"
sign: SignBody | None = None
it_reason: str | None = Field(default=None, max_length=1000)
@@ -186,17 +189,22 @@ def rev_approve(
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""A Clerical Supervisor approves the rev M-Files has in place of the rev VISUAL calls, for this draft only."""
"""A Clerical Supervisor settles a rev mismatch on a draft: M-Files' Effective rev in place of the rev VISUAL calls
(`use = latest`), or the rev VISUAL calls although M-Files has retired it (`use = called`). For this draft only."""
conn = d.conn
reason = reason_ok(body.reason)
pp = _pp(d, p)
row = packets.load_row(conn, packet_id)
staged = packets.staged_lookup(conn, row["drawing"]) if row else None
if body.use == "called":
staged = packets.called_lookup(conn, row["drawing"], row["rev"]) if row else None
else:
staged = packets.staged_lookup(conn, row["drawing"]) if row else None
content = {
"action": str(A.REV_APPROVE),
"packet_id": packet_id,
"drawing": row["drawing"] if row else None,
"visual_rev": row["rev"] if row else None,
"use": body.use,
"rev": staged["rev"] if staged else None,
"sha256": bytes(staged["sha256"]).hex() if staged and staged["sha256"] else None,
"reason": reason,
@@ -215,7 +223,17 @@ def rev_approve(
) as w:
if w.packet["source_kind"] != "visual":
raise ApiError(409, "not_visual", MSG_NOT_VISUAL)
lk = packets.staged_lookup(conn, w.packet["drawing"])
latest = packets.staged_lookup(conn, w.packet["drawing"])
if body.use == "called":
lk = packets.called_lookup(conn, w.packet["drawing"], w.packet["rev"])
if lk is None:
raise ApiError(
409,
"nothing_staged",
"M-Files has no copy of the rev VISUAL calls ready. Try after the next run.",
)
else:
lk = latest
if lk is None:
raise ApiError(
409, "nothing_staged", "No M-Files copy of this drawing is ready yet. Try after the next run."
@@ -229,13 +247,21 @@ def rev_approve(
vis_rev = w.packet["rev"] or ""
replace_drawing_pages(conn, packet_id, asset, dwg=w.packet["drawing"], rev=rev)
visual = dict(w.packet["visual"] or {})
visual["rev_ok"] = {"rev": rev, "vis_rev": vis_rev, **who_of(w), "at": now_iso(), "reason": reason}
ok = {"rev": rev, "vis_rev": vis_rev, **who_of(w), "at": now_iso(), "reason": reason}
if body.use == "called":
# The decision holds while M-Files' Effective rev stays the one Clerical chose against.
ok |= {
"use": "called",
"latest": None if latest is None else str(latest["rev"]),
"controlled": lk["controlled"],
}
visual["rev_ok"] = ok
visual["rev_confirmed"] = True
visual["vault"] = {
"asset_id": str(asset["id"]),
"name": lk["name"],
"rev": rev,
"note": lk["note"],
"note": f"{lk['controlled']} in M-Files" if body.use == "called" else lk["note"],
"at": now_iso(),
}
visual.pop("manual_dwg", None)
@@ -245,8 +271,12 @@ def rev_approve(
(flags_without(w.packet["flags"], (*packets.DRAWING_FLAGS, "hand_drawing")), packet_id),
)
w.changed("pages", "drawing_state", "flags", "visual")
w.detail.update(asset_id=str(asset["id"]), rev=rev, vis_rev=vis_rev)
w.log("Different rev approved by hand", f"rev {rev} in place of VISUAL rev {vis_rev or '-'} · {reason}")
w.detail.update(asset_id=str(asset["id"]), rev=rev, vis_rev=vis_rev, use=body.use)
if body.use == "called":
effective = f"M-Files has rev {ok['latest']}" if ok["latest"] else "no Effective rev in M-Files"
w.log("Rev VISUAL calls kept by hand", f"rev {rev} ({lk['controlled']} in M-Files), {effective} · {reason}")
else:
w.log("Different rev approved by hand", f"rev {rev} in place of VISUAL rev {vis_rev or '-'} · {reason}")
return _read(d, pp, packet_id)
+14 -1
View File
@@ -643,7 +643,20 @@ def staged_rev(conn: Any, drawing: Any) -> dict[str, Any] | None:
lk = staged_lookup(conn, drawing)
if lk is None:
return None
return {"rev": lk["rev"], "name": lk["name"]}
return {"rev": lk["rev"], "name": lk["name"], "asset_id": str(lk["asset_id"])}
def called_lookup(conn: Any, drawing: Any, rev: Any) -> dict[str, Any] | None:
"""The drawing_revisions row for the rev VISUAL calls, when the drawing job copied it (asset set)."""
dwg = str(drawing or "").strip()
r = str(rev or "").strip()
if not dwg or not r.isdigit():
return None
return one(
conn,
"SELECT * FROM drawing_revisions WHERE dwg = %s AND rev = %s AND asset_id IS NOT NULL",
(dwg, int(r)),
)
def staged_lookup(conn: Any, drawing: Any) -> dict[str, Any] | None:
+22 -3
View File
@@ -13,7 +13,10 @@ Rows, in the order they are tried:
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.
for Clerical Supervisor approval on drafts only, and on a draft also copies the rev VISUAL calls when M-Files
still holds it (Retired, say), so Clerical can choose either.
Revs differ, but Clerical chose to keep the rev VISUAL calls while M-Files' Effective rev was this one ->
unchanged (no flag, no change order). A newer Effective rev flags the packet again.
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).
@@ -69,6 +72,8 @@ class Target:
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
# Clerical chose the rev VISUAL calls (a Retired rev) over M-Files' Effective rev; this is that Effective rev.
kept_latest: str | None = None
@dataclass(frozen=True)
@@ -79,11 +84,15 @@ class Decision:
note: str
def _int(v: object) -> int | None:
def as_int(v: object) -> int | None:
"""A rev as a number: "3" -> 3; "A", "" and None -> None."""
s = "" if v is None else str(v).strip()
return int(s) if s.isdigit() else None
_int = as_int
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:
@@ -102,13 +111,21 @@ def failed_note(f: Found) -> str:
return f"{_FAILED_PREFIX}{detail or 'no detail'}"
def kept_called(t: Target, f: Found) -> bool:
"""Clerical kept the rev VISUAL calls, and M-Files' Effective rev is still the one they chose against."""
return f.ok and f.rev is not None and t.kept_latest is not None and str(t.kept_latest).strip() == str(f.rev)
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."""
"""The old revConfirmed: M-Files' released rev is the rev VISUAL calls, or Clerical approved that rev, or Clerical
kept the rev VISUAL calls over 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
if kept_called(t, f):
return True
return t.rev_ok is not None and str(t.rev_ok).strip() == str(f.rev)
@@ -156,6 +173,8 @@ def decide(t: Target, f: Found, *, same_file: bool, fetched_sha: bytes | None =
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 and kept_called(t, f):
return keep(UNCHANGED, f"Rev {vrev} kept by Clerical; M-Files has rev {f.rev}")
if not approved:
# 5
if v is None:
+40 -7
View File
@@ -4,7 +4,8 @@
`{"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>`.
An entry may also carry `"revisions": {"2": {"file": "L073762X r2.pdf", "status": "Retired", "state": "Superseded",
"obj_id": 7, "file_id": 107}}` for `revision` (other revs of the drawing, any status). `download` copies `dir/<file>`.
"""
from __future__ import annotations
@@ -60,16 +61,12 @@ class FixtureLookup:
def status(self, dwg: str) -> Found:
dwg = (dwg or "").strip()
data = self._load()
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
entry, base = self._entry(dwg)
if entry is None:
return Found(dwg=dwg, note=NOT_FOUND_NOTE)
if entry.get("ok") is False:
@@ -98,6 +95,40 @@ class FixtureLookup:
note=str(entry.get("note") or ""),
)
def _entry(self, dwg: str) -> tuple[dict[str, Any] | None, str | None]:
data = self._load()
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
return entry, base
def revision(self, dwg: str, rev: int) -> Found | None:
dwg = (dwg or "").strip()
entry, base = self._entry(dwg)
if self._error:
raise DrawingFetchError(self._error)
revs = (entry or {}).get("revisions") or {}
r = revs.get(str(int(rev))) if isinstance(revs, dict) else None
if not isinstance(r, dict) or not r.get("file"):
return None
p = self._file(str(r["file"]))
return Found(
dwg=dwg,
ok=True,
rev=int(rev),
name=str(r.get("name") or r["file"]),
obj_id=_int(r.get("obj_id", 1)),
obj_version=_int(r.get("obj_version", 1)),
file_id=_int(r.get("file_id", 1)),
file_version=_int(r.get("file_version", 1)),
file_size=p.stat().st_size if p is not None and p.is_file() else None,
base=base,
controlled=str(r.get("status") or "Retired"),
state=str(r.get("state") or ""),
)
def _file(self, name: str) -> Path | None:
root = self.dir.resolve()
p = (root / name).resolve()
@@ -105,6 +136,8 @@ class FixtureLookup:
def download(self, found: Found, dest: Path) -> int:
entry = self._load().get(found.base or found.dwg) or self._load().get(found.dwg) or {}
if found.controlled and found.rev is not None:
entry = ((entry.get("revisions") or {}).get(str(found.rev))) or {}
name = entry.get("file")
src = self._file(str(name)) if name else None
if src is None or not src.is_file():
+15 -8
View File
@@ -1,7 +1,8 @@
"""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
`drawing_lookups` row last stored (the `drawing_revisions` row for a `Lookup.revision` result), 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.
@@ -39,14 +40,19 @@ class FetchResult:
reused: bool = False # downloaded, but the same bytes were already stored
_CURRENT_SQL = (
"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 {table} l LEFT JOIN assets a ON a.id = l.asset_id"
)
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 found.controlled:
sql = _CURRENT_SQL.format(table="drawing_revisions") + " WHERE l.dwg = %s AND l.rev = %s"
row = cur.execute(sql, (dwg, found.rev)).fetchone()
else:
row = cur.execute(_CURRENT_SQL.format(table="drawing_lookups") + " 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:
@@ -68,7 +74,7 @@ def _current(conn: Any, settings: Any, found: Found, dwg: str) -> FetchResult |
def fetch(conn: Any, settings: Any, lookup: Lookup, found: Found, *, dwg: str) -> FetchResult:
"""The asset for M-Files' released file of `dwg`. Raises FetchError."""
"""The asset for M-Files' released file of `dwg` (or the rev a `Lookup.revision` found). 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)
@@ -105,6 +111,7 @@ def fetch(conn: Any, settings: Any, lookup: Lookup, found: Found, *, dwg: str) -
"file_id": found.file_id,
"file_version": found.file_version,
"rev": found.rev,
**({"controlled": found.controlled} if found.controlled else {}),
}
try:
row = assets.store_file(
+52 -2
View File
@@ -17,7 +17,7 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol
if TYPE_CHECKING:
from mfileskit.drawings import DrawingStatus
from mfileskit.drawings import DrawingStatus, RevisionFile
from mfileskit.vault import Vault
from ..settings import Settings
@@ -50,6 +50,14 @@ class Found:
draft_where: str = ""
in_drafting: bool = False
note: str = ""
# Set only on a `Lookup.revision` result: M-Files' Controlled Status for that rev ("Effective", "Retired") and
# its workflow state ("Superseded").
controlled: str = ""
state: str = ""
@property
def effective(self) -> bool:
return self.controlled.casefold() == "effective"
@property
def identity(self) -> tuple[int | None, int | None, int | None, int | None]:
@@ -63,6 +71,11 @@ def failed(dwg: str, note: str) -> Found:
class Lookup(Protocol):
def status(self, dwg: str) -> Found: ...
def revision(self, dwg: str, rev: int) -> Found | None:
"""One rev's file whatever its status (a Retired rev VISUAL still calls), with `controlled` set; None when
M-Files has no document at that rev. Raises LookupError when M-Files can't be read."""
...
def download(self, found: Found, dest: Path) -> int:
"""Write the released PDF to `dest`; bytes written. Raises DrawingFetchError (a LookupError)."""
...
@@ -91,6 +104,25 @@ def from_status(st: DrawingStatus) -> Found:
)
def from_revision(rf: RevisionFile) -> Found:
"""MFilesKit's RevisionFile -> Found, with `controlled` and `state` set."""
ov, fi = rf.obj_ver, rf.file
return Found(
dwg=rf.drawing,
ok=True,
rev=rf.rev,
name=rf.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=rf.base,
controlled=rf.status or "",
state=rf.state or "",
)
class MFilesLookup:
"""M-Files through `mfileskit.drawings.DrawingLookup`. Read-only."""
@@ -100,6 +132,7 @@ class MFilesLookup:
self.vault = vault
self._lookup = DrawingLookup(vault) # reads the vault schema; raises MFilesError when the vault is down
self._seen: dict[str, DrawingStatus] = {}
self._seen_rev: dict[tuple[str, int], RevisionFile] = {}
def status(self, dwg: str) -> Found:
from mfileskit.errors import MFilesError
@@ -112,10 +145,27 @@ class MFilesLookup:
self._seen[dwg] = st
return from_status(st)
def revision(self, dwg: str, rev: int) -> Found | None:
from mfileskit.errors import MFilesError
dwg = (dwg or "").strip()
try:
rf = self._lookup.revision(dwg, int(rev))
except MFilesError as e:
raise DrawingFetchError(f"{dwg}: M-Files lookup of rev {rev} failed: {e}"[:NOTE_MAX]) from None
if rf is None:
return None
self._seen_rev[(dwg, int(rev))] = rf
return from_revision(rf)
def download(self, found: Found, dest: Path) -> int:
from mfileskit.errors import MFilesError
st = self._seen.get(found.dwg)
st: DrawingStatus | RevisionFile | None
if found.controlled and found.rev is not None:
st = self._seen_rev.get((found.dwg, found.rev))
else:
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:
+1 -1
View File
@@ -501,7 +501,7 @@ def _update_draft(run: _Run, packet: Mapping[str, Any], c: Candidate) -> None:
state = str(row.get("drawing_state"))
if moved:
conn.execute("DELETE FROM packet_pages WHERE packet_id = %s AND type = 'drawing'", (sw.id,))
for k in ("rev_ok", "manual_dwg", "vault", "appr"):
for k in ("rev_ok", "manual_dwg", "vault", "appr", "called"):
visual.pop(k, None)
visual["rev_confirmed"] = False
flags = [f for f in flags if f not in DRAWING_FLAGS]
@@ -0,0 +1,19 @@
-- Other revs of a drawing that M-Files still holds (a Retired rev VISUAL calls), so Clerical can choose between
-- the rev VISUAL calls and the Effective one. One row per drawing number and rev. asset_id is the fetched copy.
CREATE TABLE drawing_revisions (
dwg text NOT NULL,
rev int NOT NULL,
checked_at timestamptz NOT NULL,
controlled text NOT NULL,
state text,
name text,
obj_id int,
obj_version int,
file_id int,
file_version int,
file_size bigint,
base text,
asset_id uuid REFERENCES assets (id),
sha256 bytea,
PRIMARY KEY (dwg, rev)
);
@@ -4,7 +4,9 @@ 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
change order), applied through `feed.writes.system_write`. A busy packet is left for the next run.
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.
Never writes to M-Files.
"""
@@ -56,6 +58,7 @@ def new_counts() -> Counts:
"busy": 0,
"refused": 0,
"no_file": 0,
"called_fetched": 0,
}
@@ -124,8 +127,9 @@ def _drawing(ctx: JobContext, lk: Lookup, item: queue.QueueItem, counts: Counts,
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 [])]
called: dict[int, dict[str, Any] | None] = {} # VISUAL rev -> what M-Files holds at that rev, this drawing
for pid, relations in sharing(conn, item.dwg, scope):
_packet(ctx, item.dwg, found, fetched, pid, relations, counts)
_packet(ctx, lk, item.dwg, found, fetched, pid, relations, counts, called)
# ---------------------------------------------------------------- drawing_lookups
@@ -206,6 +210,92 @@ def save_lookup(
)
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]
# ---------------------------------------------------------------- packets
@@ -307,10 +397,13 @@ def target_for(dwg: str, pf: PacketFacts, *, own: bool) -> D.Target:
co_waiting = _dwg_rev(waiting)
co_staged = _dwg_rev(staged)
packet_rev = str(row["rev"] or "").strip()
rev_ok = None
rev_ok = kept_latest = None
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:
rev_ok = str(ro["rev"]).strip()
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()
visual_rev = packet_rev if own or co_waiting is None else co_waiting[1]
return D.Target(
dwg=dwg,
@@ -326,6 +419,7 @@ def target_for(dwg: str, pf: PacketFacts, *, own: bool) -> D.Target:
co_pending=co is not None,
co_staged_sha=_sha(staged.get("sha256")) if isinstance(staged, dict) else None,
rejected_sha=pf.rejected_sha,
kept_latest=kept_latest,
)
@@ -360,12 +454,14 @@ def new_flags(flags: list[str], d: D.Decision) -> list[str]:
def _packet(
ctx: JobContext,
lk: Lookup,
dwg: str,
found: Found,
fetched: FetchResult | None,
pid: int,
relations: set[str],
counts: Counts,
called_memo: dict[int, dict[str, Any] | None],
) -> None:
conn = ctx.conn
pf = facts(conn, pid)
@@ -393,6 +489,12 @@ def _packet(
confirmed = D.rev_confirmed(t, found)
if bool(visual.get("rev_confirmed")) != confirmed:
patch["rev_confirmed"] = confirmed
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
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)
@@ -405,6 +507,11 @@ def _packet(
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
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"
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
@@ -533,4 +640,4 @@ def _cadence(fs: Any, now: datetime) -> int | None:
SPEC = JobSpec(name="drawing-lookup", run=run, cadence=_cadence, pausable=True)
__all__ = ["SPEC", "new_flags", "run", "save_lookup", "sharing", "target_for"]
__all__ = ["SPEC", "called_rev", "new_flags", "run", "save_lookup", "save_revision", "sharing", "target_for"]