"""Хранение вложений: MinIO (S3-совместимое), креды в env. Если MinIO не настроен (локальная разработка без docker-compose), объекты сохраняются в локальную папку DATA_DIR/attachments — API и карточки при этом не меняются, в БД хранится только ключ объекта. """ from __future__ import annotations import io import logging from pathlib import Path from .. import config log = logging.getLogger("leadradar.objectstore") def configured() -> bool: return bool(config.MINIO_ENDPOINT and config.MINIO_ACCESS_KEY and config.MINIO_SECRET_KEY) _client = None _client_checked = False def client(): """MinIO-клиент (создаётся лениво). Бросает ошибку, если не настроен.""" global _client, _client_checked if _client is not None: return _client if not configured(): raise RuntimeError( "MinIO не настроен: задайте LEADRADAR_MINIO_ENDPOINT / _ACCESS_KEY / _SECRET_KEY / _BUCKET" ) from minio import Minio from minio.error import S3Error _client = Minio( config.MINIO_ENDPOINT, access_key=config.MINIO_ACCESS_KEY, secret_key=config.MINIO_SECRET_KEY, secure=config.MINIO_SECURE, ) if not _client_checked: _client_checked = True try: if not _client.bucket_exists(config.MINIO_BUCKET): _client.make_bucket(config.MINIO_BUCKET) except S3Error as exc: log.warning("bucket check failed: %s", exc) return _client def _local_path(object_key: str) -> Path: # object_key вида projects/{card_id}/{ts}_{name}; не даём выйти за пределы FILES_DIR safe = Path(object_key).name parent = Path(object_key).parent return (config.FILES_DIR / parent / safe).resolve() def put(card_id: str, name: str, data: bytes, content_type: str) -> str: """Сохраняет объект (MinIO или локально), возвращает objectKey.""" import time object_key = f"projects/{card_id}/{int(time.time() * 1000)}_{name}" if configured(): client().put_object( config.MINIO_BUCKET, object_key, io.BytesIO(data), length=len(data), content_type=content_type or "application/octet-stream", ) else: path = _local_path(object_key) path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(data) log.info("MinIO не настроен — файл сохранён локально: %s", path) return object_key def get(object_key: str): """Возвращает поток (бинарный) для скачивания.""" if configured(): try: return client().get_object(config.MINIO_BUCKET, object_key) except Exception as exc: # noqa: BLE001 log.warning("minio get failed: %s", exc) raise path = _local_path(object_key) if not path.is_file(): raise FileNotFoundError(object_key) return io.BytesIO(path.read_bytes()) def remove(object_key: str) -> None: if configured(): try: client().remove_object(config.MINIO_BUCKET, object_key) except Exception as exc: # noqa: BLE001 log.warning("minio remove failed: %s", exc) return try: path = _local_path(object_key) if path.is_file(): path.unlink() except Exception as exc: # noqa: BLE001 log.warning("local remove failed: %s", exc)