Files
PacketStation/server/packetstation/drawings/fetch.py
T

126 lines
4.6 KiB
Python
Raw Normal View History

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