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]>
133 lines
5.0 KiB
Python
133 lines
5.0 KiB
Python
"""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 (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.
|
|
|
|
Read-only to M-Files: only `Lookup.download` is called.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from psycopg.rows import dict_row
|
|
|
|
from ..api.errors import ApiError
|
|
from ..domain import assets
|
|
from .lookup import Found, Lookup
|
|
|
|
PDF_MAGIC = b"%PDF-"
|
|
|
|
|
|
class FetchError(Exception):
|
|
"""The drawing could not be copied. The message is safe to log."""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class FetchResult:
|
|
asset_id: uuid.UUID
|
|
sha256: bytes
|
|
pages: int
|
|
downloaded: bool # False when the stored asset was still current
|
|
name: str | None = None
|
|
reused: bool = False # downloaded, but the same bytes were already stored
|
|
|
|
|
|
_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:
|
|
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:
|
|
return None
|
|
if None in found.identity:
|
|
return None
|
|
try:
|
|
if not assets.path_of(settings, str(row["storage_key"])).is_file():
|
|
return None
|
|
except ApiError:
|
|
return None
|
|
return FetchResult(
|
|
asset_id=row["asset_id"],
|
|
sha256=bytes(row["sha256"]),
|
|
pages=int(row["pages"] or 0),
|
|
downloaded=False,
|
|
name=row["original_name"],
|
|
)
|
|
|
|
|
|
def fetch(conn: Any, settings: Any, lookup: Lookup, found: Found, *, dwg: str) -> FetchResult:
|
|
"""The asset for M-Files' released file of `dwg` (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)
|
|
if cur is not None:
|
|
return cur
|
|
limit = int(settings.upload_max_bytes)
|
|
if found.file_size is not None and found.file_size > limit:
|
|
raise FetchError(f"{dwg}: the M-Files file is {found.file_size} bytes, over the {limit} byte limit")
|
|
tmp_dir = Path(settings.assets_dir) / "tmp"
|
|
tmp_dir.mkdir(parents=True, exist_ok=True)
|
|
tmp = tmp_dir / uuid.uuid4().hex
|
|
try:
|
|
try:
|
|
lookup.download(found, tmp)
|
|
except LookupError as e:
|
|
raise FetchError(str(e)) from None
|
|
except OSError as e:
|
|
raise FetchError(f"{dwg}: could not write the download: {type(e).__name__}") from None
|
|
try:
|
|
size = tmp.stat().st_size
|
|
with tmp.open("rb") as f:
|
|
head = f.read(len(PDF_MAGIC))
|
|
except OSError:
|
|
raise FetchError(f"{dwg}: the download left no file") from None
|
|
if size > limit:
|
|
raise FetchError(f"{dwg}: the M-Files file is {size} bytes, over the {limit} byte limit")
|
|
if head != PDF_MAGIC:
|
|
raise FetchError(f"{dwg}: the M-Files file is not a PDF")
|
|
name = found.name or f"{dwg} r{found.rev}.pdf"
|
|
source_ref = {
|
|
"dwg": dwg,
|
|
"obj_id": found.obj_id,
|
|
"obj_version": found.obj_version,
|
|
"file_id": found.file_id,
|
|
"file_version": found.file_version,
|
|
"rev": found.rev,
|
|
**({"controlled": found.controlled} if found.controlled else {}),
|
|
}
|
|
try:
|
|
row = assets.store_file(
|
|
conn, settings, tmp, mime=assets.PDF, original_name=name, source="mfiles", source_ref=source_ref
|
|
)
|
|
except ApiError as e:
|
|
raise FetchError(f"{dwg}: {e.detail}") from None
|
|
return FetchResult(
|
|
asset_id=row["id"],
|
|
sha256=bytes(row["sha256"]),
|
|
pages=int(row["pages"] or 0),
|
|
downloaded=True,
|
|
name=row.get("original_name") or name,
|
|
reused=bool(row.get("reused")),
|
|
)
|
|
finally:
|
|
with contextlib.suppress(OSError):
|
|
tmp.unlink()
|