Source code for cadrumo.domain.attachments.service

"""Service-layer helpers over :class:`AttachmentStoreProtocol`.

Thin orchestration on top of :class:`AttachmentStoreProtocol` primitives:
ingest a file from disk, build the corresponding :class:`Attachment`
manifest, persist it, and expose simple read paths for callers that
do not need the full repository API.
"""

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Literal

from pydantic import BaseModel, Field

from ...core.identity.bucket import BucketId
from ...core.logging import get_logger
from ...core.models import STRICT_FROZEN_CONFIG
from ...core.time.utc import UtcInstant
from .enums import AttachmentKind, AttachmentSource
from .models import Attachment
from .protocols import AttachmentStoreProtocol

_logger = get_logger(__name__)


[docs] class AttachmentIngestionRequest(BaseModel): """Typed manifest facts for one attachment capture.""" model_config = STRICT_FROZEN_CONFIG kind: AttachmentKind source: AttachmentSource source_reference: str mime_type: str captured_at: UtcInstant bucket_id: BucketId | None = None captured_by: str | None = None source_command: str | None = None link_transaction_ids: tuple[str, ...] = () link_invoice_ids: tuple[str, ...] = () metadata: Mapping[str, str] = Field(default_factory=dict) notes: str = ""
[docs] class AttachmentFileContent(BaseModel): """One local file supplied to the attachment custody service.""" model_config = STRICT_FROZEN_CONFIG path: Path
[docs] class AttachmentBytesContent(BaseModel): """One already-fetched byte payload supplied to the custody service.""" model_config = STRICT_FROZEN_CONFIG data: bytes
@dataclass(frozen=True) class _StoredAttachment: """Content-addressed storage result passed to the sole manifest writer.""" sha256: str bytes_size: int def _store_content( store: AttachmentStoreProtocol, content: AttachmentFileContent | AttachmentBytesContent, ) -> _StoredAttachment: """Store one typed content source and return its address and exact byte count.""" if isinstance(content, AttachmentFileContent): sha256, bytes_size = store.put_file(content.path) return _StoredAttachment(sha256=sha256, bytes_size=bytes_size) sha256 = store.put_bytes(content.data) return _StoredAttachment(sha256=sha256, bytes_size=len(content.data)) def _persist_attachment( store: AttachmentStoreProtocol, *, stored: _StoredAttachment, request: AttachmentIngestionRequest, ) -> Attachment: """Build and persist the manifest through the sole attachment creation write path.""" attachment = Attachment.model_validate( { "attachment_id": stored.sha256, "kind": request.kind, "source": request.source, "source_reference": request.source_reference, "sha256": stored.sha256, "mime_type": request.mime_type, "bytes_size": stored.bytes_size, "captured_at": request.captured_at, "captured_by": request.captured_by, "source_command": request.source_command, "linked_transaction_ids": request.link_transaction_ids, "linked_invoice_ids": request.link_invoice_ids, "bucket_id": request.bucket_id, "metadata": request.metadata, "notes": request.notes, }, ) store.write_manifest(attachment) return attachment
[docs] def add_attachment( store: AttachmentStoreProtocol, *, content: AttachmentFileContent | AttachmentBytesContent, request: AttachmentIngestionRequest, ) -> Attachment: """Store one typed file or byte payload and persist its attachment manifest.""" _logger.debug("ingesting attachment kind=%s source=%s", request.kind.value, request.source.value) stored = _store_content(store, content) attachment = _persist_attachment( store, stored=stored, request=request, ) _logger.info( "added attachment kind=%s source=%s bytes=%d", request.kind.value, request.source.value, stored.bytes_size, ) return attachment
def _link_attachment( store: AttachmentStoreProtocol, *, attachment_id: str, related_id: str, field: Literal["linked_invoice_ids", "linked_transaction_ids"], related_kind: Literal["invoice", "transaction"], ) -> Attachment: """Append one typed relation to a manifest through the sole update write path.""" attachment = store.load_manifest(attachment_id) related_ids = attachment.linked_invoice_ids if field == "linked_invoice_ids" else attachment.linked_transaction_ids if related_id in related_ids: return attachment updated = attachment.model_copy(update={field: (*related_ids, related_id)}) store.write_manifest(updated) _logger.info("linked attachment %s to %s %s", attachment_id, related_kind, related_id) return updated