Files
IPSExtractor/services/bot_descarga/infrastructure/storage_minio.py
T

216 lines
7.4 KiB
Python

from __future__ import annotations
import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import BinaryIO, List, Optional
@dataclass(frozen=True)
class MinioSettings:
endpoint: str = "https://s3.builker.xyz"
bucket: str = "pys-cuentas-medicas"
access_key: str = ""
secret_key: str = ""
region: str = "us-east-1"
anonymous: bool = True
@classmethod
def from_env(cls) -> "MinioSettings":
access_key = (os.getenv("MINIO_ACCESS_KEY") or "").strip()
secret_key = (os.getenv("MINIO_SECRET_KEY") or "").strip()
anon_flag = (os.getenv("MINIO_ANONYMOUS") or "").strip().lower()
if anon_flag in ("0", "false", "no"):
anonymous = False
elif anon_flag in ("1", "true", "yes"):
anonymous = True
else:
anonymous = not (access_key and secret_key)
return cls(
endpoint=os.getenv("MINIO_ENDPOINT", "https://s3.builker.xyz").rstrip("/"),
bucket=os.getenv("MINIO_BUCKET", "pys-cuentas-medicas"),
access_key=access_key,
secret_key=secret_key,
region=os.getenv("MINIO_REGION", "us-east-1"),
anonymous=anonymous,
)
def excel_key(job_id: str, filename: str = "input.xlsx") -> str:
return f"uploads/{job_id}/{filename}"
def downloads_prefix(job_id: str) -> str:
return f"downloads/{job_id}/"
def pdf_key(job_id: str, nit: str, radicado: str) -> str:
return f"downloads/{job_id}/{nit}/{radicado}.pdf"
def meta_key(job_id: str) -> str:
return f"uploads/{job_id}/meta.json"
def manifest_key(job_id: str) -> str:
return f"downloads/{job_id}/_manifest.json"
def _s3_client(settings: MinioSettings):
import boto3
from botocore import UNSIGNED
from botocore.client import Config
s3_cfg = {
"addressing_style": "path",
"request_checksum_calculation": "when_required",
"response_checksum_validation": "when_required",
}
if settings.anonymous:
return boto3.client(
"s3",
endpoint_url=settings.endpoint,
region_name=settings.region,
config=Config(signature_version=UNSIGNED, s3=s3_cfg),
)
return boto3.client(
"s3",
endpoint_url=settings.endpoint,
aws_access_key_id=settings.access_key,
aws_secret_access_key=settings.secret_key,
region_name=settings.region,
config=Config(signature_version="s3v4", s3=s3_cfg),
)
class MinioStorage:
"""Cliente S3-compatible para MinIO (uploads/downloads por job_id)."""
def __init__(self, settings: Optional[MinioSettings] = None):
self.settings = settings or MinioSettings.from_env()
self._client = None
@property
def client(self):
if self._client is None:
self._client = _s3_client(self.settings)
return self._client
@property
def bucket(self) -> str:
return self.settings.bucket
def put_bytes(self, key: str, data: bytes, content_type: str = "application/octet-stream") -> str:
self.client.put_object(
Bucket=self.bucket,
Key=key,
Body=data,
ContentType=content_type,
)
return key
def put_file(self, key: str, path: str | Path, content_type: str = "application/octet-stream") -> str:
path = Path(path)
return self.put_bytes(key, path.read_bytes(), content_type=content_type)
def put_stream(self, key: str, stream: BinaryIO, content_type: str = "application/octet-stream") -> str:
data = stream.read()
return self.put_bytes(key, data, content_type=content_type)
def get_bytes(self, key: str) -> bytes:
obj = self.client.get_object(Bucket=self.bucket, Key=key)
return obj["Body"].read()
def download_file(self, key: str, dest: str | Path) -> Path:
dest = Path(dest)
dest.parent.mkdir(parents=True, exist_ok=True)
self.client.download_file(self.bucket, key, str(dest))
return dest
def exists(self, key: str) -> bool:
try:
self.client.head_object(Bucket=self.bucket, Key=key)
return True
except Exception:
return False
def list_keys(self, prefix: str) -> List[str]:
keys: List[str] = []
token = None
while True:
kwargs = {"Bucket": self.bucket, "Prefix": prefix}
if token:
kwargs["ContinuationToken"] = token
resp = self.client.list_objects_v2(**kwargs)
for item in resp.get("Contents") or []:
keys.append(item["Key"])
if not resp.get("IsTruncated"):
break
token = resp.get("NextContinuationToken")
return keys
def presigned_get(self, key: str, expires_in: int = 3600) -> str:
if self.settings.anonymous:
return f"{self.settings.endpoint}/{self.bucket}/{key}"
return self.client.generate_presigned_url(
"get_object",
Params={"Bucket": self.bucket, "Key": key},
ExpiresIn=expires_in,
)
def upload_excel(
self,
job_id: str,
data: bytes,
original_filename: str,
content_type: str = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
) -> str:
ext = Path(original_filename).suffix.lower() or ".xlsx"
canonical = f"input{ext}" if ext in (".xlsx", ".xls", ".csv") else "input.xlsx"
key = excel_key(job_id, canonical)
self.put_bytes(key, data, content_type=content_type)
meta = {
"original_filename": original_filename,
"size": len(data),
"canonical": canonical,
}
self.put_bytes(meta_key(job_id), json.dumps(meta).encode("utf-8"), "application/json")
return key
def upload_pdf(self, job_id: str, nit: str, radicado: str, local_path: str | Path) -> str:
key = pdf_key(job_id, nit, radicado)
self.put_file(key, local_path, content_type="application/pdf")
return key
def write_manifest(self, job_id: str, keys: List[str]) -> str:
key = manifest_key(job_id)
payload = {"job_id": job_id, "files": keys, "count": len(keys)}
self.put_bytes(key, json.dumps(payload, ensure_ascii=False).encode("utf-8"), "application/json")
return key
def list_download_files(self, job_id: str, prefix: str | None = None) -> dict:
"""Lista PDFs de una corrida (manifest o list_keys)."""
prefix = prefix or downloads_prefix(job_id)
files: List[dict] = []
try:
raw = self.get_bytes(manifest_key(job_id))
payload = json.loads(raw.decode("utf-8"))
for key in payload.get("files") or []:
files.append(self._file_entry(str(key)))
except Exception:
for key in self.list_keys(prefix):
if key.endswith("/_manifest.json") or key.endswith("_manifest.json"):
continue
files.append(self._file_entry(key))
return {"job_id": job_id, "prefix": prefix, "files": files, "count": len(files)}
@staticmethod
def _file_entry(key: str) -> dict:
# downloads/{job_id}/{nit}/{radicado}.pdf
parts = key.strip("/").split("/")
nit = parts[-2] if len(parts) >= 3 else None
name = parts[-1] if parts else key
radicado = name[:-4] if name.lower().endswith(".pdf") else name
return {"key": key, "nit": nit, "radicado": radicado}