"""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/; 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()