Migration 0004 (VISUAL and drawing caches, go-live ignored list, resource map, job tables, order-derived columns with provenance; work order key includes the operation sequence). The real worker on APScheduler with settings re-read each fire, pause, run now, advisory locks, job_runs and a heartbeat. The visual-feed job (five VisualKit reads, COPY and MERGE cache, suspect-batch guard, the apply in one transaction: drafts, change orders, retire-moved, work order gone, closed and cancelled observed, projects and units from order links, auto-release) and the fingerprint. The drawing-lookup job (queue, budget, download on version change, sha256 into assets, decision table, staging for rev approval, drawing-origin change orders). Clerical From VISUAL panel, flags, rev approval, hand uploads, change-order review; Admin Feed settings and resource map. Synthetic fixture generator and a fixture-driven worker in CI with a Playwright scenario. Docs updated. Verified: 3249 server tests and 13 Playwright scenarios on the docker-1 CI stack, 76 frontend tests, doctor at 0004; one feed and one drawing run against VISUAL DEV and the vault from the developer machine (counts in the changelog). Co-Authored-By: Claude Fable 5.1 <[email protected]>
126 lines
4.6 KiB
Python
126 lines
4.6 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, 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
|
|
|
|
|
|
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 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`. 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,
|
|
}
|
|
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()
|