from __future__ import annotations import base64 import binascii import mimetypes import re from pathlib import Path from typing import Any from urllib.parse import quote from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage class ExpenseClaimAttachmentPresentation: def __init__(self, storage: ExpenseClaimAttachmentStorage) -> None: self.storage = storage def build_preview_meta( self, *, file_path: Path, media_type: str, ocr_document: Any | None, ) -> dict[str, Any]: filename = file_path.name storage_key = self.storage.to_storage_key(file_path) preview_kind = self.resolve_preview_kind(media_type, filename) preview_data_url = str(getattr(ocr_document, "preview_data_url", "") or "").strip() preview_source_kind = str(getattr(ocr_document, "preview_kind", "") or "").strip() if preview_source_kind == "image" and preview_data_url: preview_asset = self._write_preview_asset_from_data_url( attachment_dir=file_path.parent, original_filename=filename, preview_data_url=preview_data_url, ) if preview_asset is not None: preview_path, preview_media_type, preview_file_name = preview_asset return { "previewable": True, "preview_kind": "image", "preview_storage_key": self.storage.to_storage_key(preview_path), "preview_media_type": preview_media_type, "preview_file_name": preview_file_name, } if preview_kind: return { "previewable": True, "preview_kind": preview_kind, "preview_storage_key": storage_key, "preview_media_type": media_type, "preview_file_name": filename, } return { "previewable": False, "preview_kind": "", "preview_storage_key": "", "preview_media_type": "", "preview_file_name": "", } @staticmethod def resolve_preview_kind(media_type: str | None, filename: str) -> str: resolved = str(media_type or "").strip() or (mimetypes.guess_type(filename)[0] or "") if resolved.startswith("image/"): return "image" if resolved == "application/pdf": return "pdf" return "" @staticmethod def decode_data_url(payload: str) -> tuple[str, bytes] | None: normalized = str(payload or "").strip() matched = re.match(r"^data:(?P[\w.+-]+/[\w.+-]+);base64,(?P.+)$", normalized, flags=re.DOTALL) if not matched: return None try: content = base64.b64decode(matched.group("body"), validate=True) except (binascii.Error, ValueError): return None return matched.group("media"), content def _write_preview_asset_from_data_url( self, *, attachment_dir: Path, original_filename: str, preview_data_url: str, ) -> tuple[Path, str, str] | None: decoded = self.decode_data_url(preview_data_url) if decoded is None: return None preview_media_type, preview_content = decoded suffix = mimetypes.guess_extension(preview_media_type) or ".bin" preview_name = f"{Path(original_filename).stem}.preview{suffix}" preview_path = attachment_dir / preview_name preview_path.write_bytes(preview_content) return preview_path, preview_media_type, preview_name @staticmethod def build_preview_client_path(claim_id: str, item_id: str) -> str: return ( "/reimbursements/claims/" f"{quote(str(claim_id or '').strip(), safe='')}" f"/items/{quote(str(item_id or '').strip(), safe='')}/attachment/preview" ) @staticmethod def resolve_media_type(filename: str, *, fallback: str | None = None) -> str: guessed = mimetypes.guess_type(filename)[0] return str(guessed or fallback or "application/octet-stream") @staticmethod def is_previewable_media_type(media_type: str | None, filename: str) -> bool: resolved = str(media_type or "").strip() or (mimetypes.guess_type(filename)[0] or "") return resolved.startswith("image/") or resolved == "application/pdf" @staticmethod def resolve_display_name(storage_key: str | None) -> str: return Path(str(storage_key or "").strip()).name @classmethod def merge_reference(cls, current_invoice_id: str | None, next_invoice_id: str | None) -> str | None: normalized_next = str(next_invoice_id or "").strip() if not normalized_next: return None normalized_current = str(current_invoice_id or "").strip() if ( normalized_current and cls.resolve_display_name(normalized_current) == cls.resolve_display_name(normalized_next) ): return normalized_current return normalized_next