174 lines
5.0 KiB
Python
174 lines
5.0 KiB
Python
"""
|
|
Cliente S3 (MinIO): bucket, objetos genéricos, presign, imports CSV.
|
|
|
|
Las claves de objeto deben generarse con ``core.s3_keys`` (p. ej. ``csv_import_key`` vía
|
|
``s3_key_for_csv_import``); no construir prefijos ``tenants/...`` aquí.
|
|
"""
|
|
import logging
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
import boto3
|
|
from botocore.config import Config
|
|
from botocore.exceptions import ClientError
|
|
|
|
from core.config import settings
|
|
from core.s3_keys import csv_import_key
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _client():
|
|
return boto3.client(
|
|
"s3",
|
|
endpoint_url=settings.S3_ENDPOINT_URL,
|
|
aws_access_key_id=settings.S3_ACCESS_KEY,
|
|
aws_secret_access_key=settings.S3_SECRET_KEY,
|
|
region_name=settings.S3_REGION,
|
|
use_ssl=settings.S3_USE_SSL,
|
|
config=Config(
|
|
signature_version="s3v4",
|
|
s3={"addressing_style": "path"},
|
|
),
|
|
)
|
|
|
|
|
|
def should_ensure_s3_bucket() -> bool:
|
|
return settings.use_s3_object_storage
|
|
|
|
|
|
def ensure_s3_bucket() -> None:
|
|
"""Crea el bucket si no existe (idempotente)."""
|
|
if not should_ensure_s3_bucket():
|
|
return
|
|
bucket = settings.S3_BUCKET
|
|
client = _client()
|
|
try:
|
|
client.head_bucket(Bucket=bucket)
|
|
logger.info("S3 bucket %s exists", bucket)
|
|
return
|
|
except ClientError as e:
|
|
code = e.response.get("Error", {}).get("Code", "")
|
|
if code not in ("404", "NoSuchBucket", "403"):
|
|
logger.warning("head_bucket %s: %s", bucket, e)
|
|
try:
|
|
if settings.S3_REGION == "us-east-1":
|
|
client.create_bucket(Bucket=bucket)
|
|
else:
|
|
client.create_bucket(
|
|
Bucket=bucket,
|
|
CreateBucketConfiguration={"LocationConstraint": settings.S3_REGION},
|
|
)
|
|
logger.info("S3 bucket %s created", bucket)
|
|
except ClientError as e:
|
|
logger.error("create_bucket %s failed: %s", bucket, e)
|
|
raise
|
|
|
|
|
|
# Alias para código existente
|
|
def ensure_csv_import_bucket() -> None:
|
|
ensure_s3_bucket()
|
|
|
|
|
|
def put_object_bytes(key: str, body: bytes, content_type: str = "application/octet-stream") -> None:
|
|
_client().put_object(
|
|
Bucket=settings.S3_BUCKET,
|
|
Key=key,
|
|
Body=body,
|
|
ContentType=content_type,
|
|
)
|
|
|
|
|
|
def put_csv_object(key: str, body: bytes, content_type: str = "text/csv") -> None:
|
|
put_object_bytes(key, body, content_type=content_type)
|
|
|
|
|
|
def get_object_bytes(key: str) -> bytes:
|
|
resp = _client().get_object(Bucket=settings.S3_BUCKET, Key=key)
|
|
return resp["Body"].read()
|
|
|
|
|
|
def delete_object_if_exists(key: str) -> None:
|
|
try:
|
|
_client().delete_object(Bucket=settings.S3_BUCKET, Key=key)
|
|
except ClientError as e:
|
|
logger.warning("delete_object %s: %s", key, e)
|
|
|
|
|
|
def object_exists(key: str) -> bool:
|
|
try:
|
|
_client().head_object(Bucket=settings.S3_BUCKET, Key=key)
|
|
return True
|
|
except ClientError:
|
|
return False
|
|
|
|
|
|
def presigned_get_url(key: str, expires_in: Optional[int] = None) -> str:
|
|
sec = expires_in if expires_in is not None else settings.S3_PRESIGNED_EXPIRES_SECONDS
|
|
return _client().generate_presigned_url(
|
|
"get_object",
|
|
Params={"Bucket": settings.S3_BUCKET, "Key": key},
|
|
ExpiresIn=sec,
|
|
)
|
|
|
|
|
|
def list_objects_tree(
|
|
prefix: str,
|
|
delimiter: str = "/",
|
|
max_keys: int = 100,
|
|
continuation_token: Optional[str] = None,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Lista objetos/prefijos como árbol virtual.
|
|
|
|
Retorna:
|
|
- ``prefixes``: subcarpetas (CommonPrefixes)
|
|
- ``objects``: objetos directos bajo ``prefix``
|
|
- ``next_continuation_token`` y ``is_truncated`` para paginación
|
|
"""
|
|
params: Dict[str, Any] = {
|
|
"Bucket": settings.S3_BUCKET,
|
|
"Prefix": prefix,
|
|
"Delimiter": delimiter,
|
|
"MaxKeys": max(1, min(int(max_keys), 500)),
|
|
}
|
|
if continuation_token:
|
|
params["ContinuationToken"] = continuation_token
|
|
|
|
resp = _client().list_objects_v2(**params)
|
|
common_prefixes: List[str] = [
|
|
p.get("Prefix", "") for p in (resp.get("CommonPrefixes") or []) if p.get("Prefix")
|
|
]
|
|
objects: List[Dict[str, Any]] = []
|
|
for obj in resp.get("Contents") or []:
|
|
key = obj.get("Key")
|
|
if not key:
|
|
continue
|
|
if key == prefix:
|
|
# Marcador de carpeta (objeto vacío con mismo nombre del prefijo).
|
|
continue
|
|
objects.append(
|
|
{
|
|
"key": key,
|
|
"size": int(obj.get("Size", 0) or 0),
|
|
"last_modified": obj.get("LastModified"),
|
|
"etag": obj.get("ETag"),
|
|
"storage_class": obj.get("StorageClass"),
|
|
}
|
|
)
|
|
|
|
return {
|
|
"prefixes": common_prefixes,
|
|
"objects": objects,
|
|
"next_continuation_token": resp.get("NextContinuationToken"),
|
|
"is_truncated": bool(resp.get("IsTruncated")),
|
|
}
|
|
|
|
|
|
def s3_key_for_csv_import(
|
|
tenant_id,
|
|
company_id: int,
|
|
job_type: str,
|
|
job_id: str,
|
|
) -> str:
|
|
return csv_import_key(tenant_id, company_id, job_type, job_id)
|