drawings: on a rev mismatch, Clerical can keep the retired rev VISUAL calls (#41)
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:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user