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
+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: