From 9439a6a77520f61622fd5063ec80929a5a98a764 Mon Sep 17 00:00:00 2001 From: hreyes Date: Thu, 2 Apr 2026 13:35:18 -0600 Subject: [PATCH] feature/minio-s3-integration --- .env.example | 19 + ...d4e5f6a7b8c9_company_logo_s3_key_length.py | 39 +++ .../v1/modules/a76/customs_brokers/routes.py | 303 +++++++++++++++- .../modules/a76/customs_brokers/services.py | 20 ++ .../a76/general_catalogs/company/dto.py | 2 +- .../a76/general_catalogs/company/models.py | 2 +- .../a76/general_catalogs/company/routes.py | 145 +++++--- .../a76/general_catalogs/company/service.py | 5 + .../v1/modules/a76/layouts_csv/boms/routes.py | 37 +- .../cambio_regimen_regularizacion/routes.py | 31 +- .../modules/a76/layouts_csv/classes/routes.py | 37 +- .../clients_and_providers/routes.py | 37 +- .../modules/a76/layouts_csv/common/storage.py | 118 ++++++- .../a76/layouts_csv/customs_brokers/routes.py | 37 +- .../modules/a76/layouts_csv/drivers/routes.py | 38 +- .../a76/layouts_csv/exchange_rate/routes.py | 37 +- .../a76/layouts_csv/exportacion/routes.py | 31 +- .../a76/layouts_csv/facturas/routes.py | 42 +-- .../modules/a76/layouts_csv/parts/routes.py | 37 +- .../a76/layouts_csv/pedmientos/routes.py | 37 +- .../a76/layouts_csv/trailers/routes.py | 38 +- .../a76/layouts_csv/transportistas/routes.py | 38 +- .../layouts_csv/us_tariff_fractions/routes.py | 37 +- .../a76/layouts_csv/vehicles/routes.py | 38 +- .../api/v1/modules/core/help_center/routes.py | 108 ++++-- .../api/v1/modules/core/help_center/utils.py | 129 ++++--- backend/api/v1/modules/core/users/routes.py | 135 +++++++- backend/api/v1/modules/core/users/service.py | 17 +- backend/core/config.py | 22 +- backend/core/middleware.py | 2 + backend/core/s3_keys.py | 325 ++++++++++++++++++ backend/core/storage_s3.py | 120 +++++++ backend/main.py | 5 +- backend/requirements.txt | 1 + docker-compose.prod.yml | 56 +++ docker-compose.yml | 56 +++ .../lib/api/dashboard/a76/customs-brokers.ts | 56 ++- .../dashboard/a76/general_catalogs/company.ts | 13 +- frontend/src/lib/utils.ts | 43 ++- .../customs_brokers/edit/[[id]]/+page.svelte | 154 ++++++++- .../edit/[[id]]/+page.svelte | 36 +- .../help-center/editor/[uuid]/+page.svelte | 5 +- 42 files changed, 1930 insertions(+), 558 deletions(-) create mode 100644 backend/alembic/versions/d4e5f6a7b8c9_company_logo_s3_key_length.py create mode 100644 backend/core/s3_keys.py create mode 100644 backend/core/storage_s3.py diff --git a/.env.example b/.env.example index 46bb47bc..1ef60c19 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,25 @@ VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend #------ Celery / Valkey ---------- VALKEY_URL=redis://valkey:6379/0 +# ----- MinIO (S3-compatible) ----- +MINIO_ROOT_USER=minioadmin +MINIO_ROOT_PASSWORD=minioadmin +MINIO_API_PORT=9100 +MINIO_CONSOLE_PORT=9101 + +# ----- Imports CSV (layouts_csv): redis | minio ----- +CSV_IMPORT_STORAGE=minio +S3_ENDPOINT_URL=http://minio:9000 +S3_ACCESS_KEY=minioadmin +S3_SECRET_KEY=minioadmin +S3_BUCKET=anexo76 +S3_REGION=us-east-1 +S3_USE_SSL=false +# Logos, certificados, help: mismo bucket. Si CSV_IMPORT_STORAGE=minio, también se usa MinIO aquí +# (use_s3_object_storage = minio CSV o S3_FILE_STORAGE=true). +S3_FILE_STORAGE=true +S3_PRESIGNED_EXPIRES_SECONDS=3600 + # ----- Sitar API ----- SITAR_API_URL=http://api.sitar.aduanasoft.com SITAR_API_USER=user_sitar_api diff --git a/backend/alembic/versions/d4e5f6a7b8c9_company_logo_s3_key_length.py b/backend/alembic/versions/d4e5f6a7b8c9_company_logo_s3_key_length.py new file mode 100644 index 00000000..2b5bc3f6 --- /dev/null +++ b/backend/alembic/versions/d4e5f6a7b8c9_company_logo_s3_key_length.py @@ -0,0 +1,39 @@ +"""extend company.logo for S3 keys + +Revision ID: d4e5f6a7b8c9 +Revises: ca7d3c4e8b2a +Create Date: 2026-04-02 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "d4e5f6a7b8c9" +down_revision: Union[str, None] = "ca7d3c4e8b2a" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.alter_column( + "company", + "logo", + existing_type=sa.String(length=255), + type_=sa.String(length=512), + existing_nullable=True, + schema="a76", + ) + + +def downgrade() -> None: + op.alter_column( + "company", + "logo", + existing_type=sa.String(length=512), + type_=sa.String(length=255), + existing_nullable=True, + schema="a76", + ) diff --git a/backend/api/v1/modules/a76/customs_brokers/routes.py b/backend/api/v1/modules/a76/customs_brokers/routes.py index 960fb1f9..8635ff2e 100644 --- a/backend/api/v1/modules/a76/customs_brokers/routes.py +++ b/backend/api/v1/modules/a76/customs_brokers/routes.py @@ -1,16 +1,69 @@ -from typing import Dict, Any -from fastapi import APIRouter, Depends, HTTPException, Query +import logging +import mimetypes +import os +from datetime import datetime +from typing import Any, Dict, Optional + +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status from sqlalchemy.orm import Session +from core.config import settings from core.database import get_core_db -from core.security import get_current_user, validate_access_to_resource +from core.s3_keys import ( + customs_broker_vu_certificate_key, + customs_broker_vu_cove_key, + customs_broker_vu_doda_certificate_key, + customs_broker_vu_doda_cove_key, + customs_broker_vu_doda_private_key_key, + customs_broker_vu_private_key_key, +) +from core.security import get_current_user, get_tenant_from_token, validate_access_to_resource +from core.storage_s3 import delete_object_if_exists, put_object_bytes from api.v1.common.tenant_crud_routes import TenantCRUDRoutes from . import dto, services from ..layouts_csv.customs_brokers.routes import router as imports_router +logger = logging.getLogger(__name__) + router = APIRouter() +MAX_VU_CER_KEY_BYTES = 5 * 1024 * 1024 # 5 MB +MAX_COVE_BYTES = 15 * 1024 * 1024 # 15 MB (xml/zip) + + +def _resolve_tenant_id_int(current_user: dict) -> int: + tid = get_tenant_from_token(current_user) + if tid is not None: + return int(tid) + raw = current_user.get("tenant_id") + if isinstance(raw, list) and raw: + raw = raw[0] + if raw is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Tenant ID not found in user data", + ) + try: + return int(raw) + except (TypeError, ValueError): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid tenant ID in token", + ) + + +def _remove_stored_vu_path(ref: Optional[str]) -> None: + if not ref: + return + if ref.startswith("tenants/"): + delete_object_if_exists(ref) + elif os.path.isfile(ref): + try: + os.remove(ref) + except OSError: + pass + # CSV import (mismo flujo que a76.imports: upload → scan → commit) router.include_router(imports_router, prefix="/customs-brokers/imports", tags=["customs_brokers / csv_import"]) @@ -112,4 +165,246 @@ def update_customs_broker_personnel( raise HTTPException( status_code=404, detail="Customs Broker Personnel not found" ) - return updated_personnel \ No newline at end of file + return updated_personnel + + +@router.post( + "/customs-brokers/{broker_key}/vu/upload", + summary="Sube VU/DODA (CER, KEY, COVE) al bucket bajo tenants/.../customs_brokers/{id}/...", +) +async def upload_customs_broker_vu_file( + broker_key: str, + file_kind: str = Query( + ..., + description=( + "certificate (.cer), key (.key), cove (xml/zip/txt/pdf/json), " + "doda_certificate (.cer), doda_key (.key), doda_cove (xml/zip/txt/pdf/json)" + ), + ), + company_id: int = Query(..., description="Company ID"), + file: UploadFile = File(...), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Persiste el archivo bajo la misma jerarquía que logos/avatares (tenant/company/...). + Guarda la clave S3 o ruta local en: + - VU: certificate_path, key_path, xml_files_path + - DODA: doda_certificate_path, doda_key_path, doda_xml_files_path + """ + validate_access_to_resource(db, company_id, current_user) + tenant_id = _resolve_tenant_id_int(current_user) + + broker = services.CustomsBrokerService.get_by_id(db, broker_key, tenant_id, company_id) + if not broker: + raise HTTPException(status_code=404, detail="Customs Broker not found") + + fk = file_kind.lower().strip() + if fk not in ( + "certificate", + "key", + "cove", + "doda_certificate", + "doda_key", + "doda_cove", + ): + raise HTTPException( + status_code=400, + detail=( + "file_kind must be certificate, key, cove, " + "doda_certificate, doda_key, or doda_cove" + ), + ) + + content = await file.read() + max_bytes = MAX_COVE_BYTES if fk in ("cove", "doda_cove") else MAX_VU_CER_KEY_BYTES + if len(content) > max_bytes: + raise HTTPException( + status_code=400, + detail=f"File too large (max {max_bytes // (1024 * 1024)} MB)", + ) + + file_ext = os.path.splitext(file.filename or "")[1].lower() + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + vu = services.CustomsBrokerVUService.ensure_vu_for_broker(db, broker) + broker_id = broker.id + + field_name: str + stored: str + + try: + if settings.use_s3_object_storage: + if fk == "certificate": + if file_ext != ".cer": + raise HTTPException(status_code=400, detail="certificate must be .cer") + key = customs_broker_vu_certificate_key( + tenant_id, company_id, broker_id, timestamp, file_ext + ) + ct = "application/x-x509-ca-cert" + field_name = "certificate_path" + elif fk == "key": + if file_ext != ".key": + raise HTTPException(status_code=400, detail="key must be .key") + key = customs_broker_vu_private_key_key( + tenant_id, company_id, broker_id, timestamp, file_ext + ) + ct = "application/pkcs8" + field_name = "key_path" + elif fk == "cove": + key = customs_broker_vu_cove_key( + tenant_id, + company_id, + broker_id, + timestamp, + file.filename or "cove.xml", + ) + ct = ( + file.content_type + or mimetypes.guess_type(file.filename or "")[0] + or "application/octet-stream" + ) + field_name = "xml_files_path" + elif fk == "doda_certificate": + if file_ext != ".cer": + raise HTTPException(status_code=400, detail="doda_certificate must be .cer") + key = customs_broker_vu_doda_certificate_key( + tenant_id, company_id, broker_id, timestamp, file_ext + ) + ct = "application/x-x509-ca-cert" + field_name = "doda_certificate_path" + elif fk == "doda_key": + if file_ext != ".key": + raise HTTPException(status_code=400, detail="doda_key must be .key") + key = customs_broker_vu_doda_private_key_key( + tenant_id, company_id, broker_id, timestamp, file_ext + ) + ct = "application/pkcs8" + field_name = "doda_key_path" + else: + key = customs_broker_vu_doda_cove_key( + tenant_id, + company_id, + broker_id, + timestamp, + file.filename or "doda.xml", + ) + ct = ( + file.content_type + or mimetypes.guess_type(file.filename or "")[0] + or "application/octet-stream" + ) + field_name = "doda_xml_files_path" + + old = getattr(vu, field_name) + _remove_stored_vu_path(old) + put_object_bytes(key, content, content_type=ct) + logger.info( + "Customs broker VU upload kind=%s key=%s bytes=%s", + fk, + key, + len(content), + ) + stored = key + else: + base = os.path.join( + "uploads", "customs_brokers", str(company_id), str(broker_id) + ) + if fk == "certificate": + if file_ext != ".cer": + raise HTTPException(status_code=400, detail="certificate must be .cer") + key = customs_broker_vu_certificate_key( + tenant_id, company_id, broker_id, timestamp, file_ext + ) + field_name = "certificate_path" + subdir = "certificates" + elif fk == "key": + if file_ext != ".key": + raise HTTPException(status_code=400, detail="key must be .key") + key = customs_broker_vu_private_key_key( + tenant_id, company_id, broker_id, timestamp, file_ext + ) + field_name = "key_path" + subdir = "keys" + elif fk == "cove": + try: + key = customs_broker_vu_cove_key( + tenant_id, + company_id, + broker_id, + timestamp, + file.filename or "cove.xml", + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + field_name = "xml_files_path" + subdir = "cove" + elif fk == "doda_certificate": + if file_ext != ".cer": + raise HTTPException(status_code=400, detail="doda_certificate must be .cer") + key = customs_broker_vu_doda_certificate_key( + tenant_id, company_id, broker_id, timestamp, file_ext + ) + field_name = "doda_certificate_path" + subdir = "doda/certificates" + elif fk == "doda_key": + if file_ext != ".key": + raise HTTPException(status_code=400, detail="doda_key must be .key") + key = customs_broker_vu_doda_private_key_key( + tenant_id, company_id, broker_id, timestamp, file_ext + ) + field_name = "doda_key_path" + subdir = "doda/keys" + else: + try: + key = customs_broker_vu_doda_cove_key( + tenant_id, + company_id, + broker_id, + timestamp, + file.filename or "doda.xml", + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + field_name = "doda_xml_files_path" + subdir = "doda/cove" + + fname = key.rsplit("/", 1)[-1] + dest_dir = os.path.join(base, subdir) + os.makedirs(dest_dir, exist_ok=True) + path = os.path.join(dest_dir, fname) + old = getattr(vu, field_name) + _remove_stored_vu_path(old) + with open(path, "wb") as f: + f.write(content) + logger.info( + "Customs broker VU upload kind=%s path=%s bytes=%s", + fk, + path, + len(content), + ) + stored = path + + setattr(vu, field_name, stored) + db.add(vu) + db.commit() + db.refresh(vu) + except HTTPException: + db.rollback() + raise + except ValueError as e: + db.rollback() + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + db.rollback() + raise HTTPException( + status_code=500, detail=f"Error saving file: {str(e)}" + ) from e + + return { + "message": "File uploaded successfully", + "file_kind": fk, + "field": field_name, + "path": stored, + "broker_key": broker_key, + "company_id": company_id, + } \ No newline at end of file diff --git a/backend/api/v1/modules/a76/customs_brokers/services.py b/backend/api/v1/modules/a76/customs_brokers/services.py index 6e62e896..32ae8469 100644 --- a/backend/api/v1/modules/a76/customs_brokers/services.py +++ b/backend/api/v1/modules/a76/customs_brokers/services.py @@ -160,6 +160,26 @@ class CustomsBrokerVUService: db.commit() return vu + @staticmethod + def ensure_vu_for_broker(db: Session, broker: models.CustomsBroker) -> models.CustomsBrokerVU: + """Crea fila VU vacía si no existe (p. ej. antes de subir CER/KEY/COVE al bucket).""" + vu = ( + db.query(models.CustomsBrokerVU) + .filter(models.CustomsBrokerVU.customs_broker_id == broker.id) + .first() + ) + if vu: + return vu + vu = models.CustomsBrokerVU( + customs_broker_id=broker.id, + tenant_id=broker.tenant_id, + company_id=broker.company_id, + ) + db.add(vu) + db.commit() + db.refresh(vu) + return vu + class CustomsBrokerPersonnelService: @staticmethod diff --git a/backend/api/v1/modules/a76/general_catalogs/company/dto.py b/backend/api/v1/modules/a76/general_catalogs/company/dto.py index a83faf81..d96f582a 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/dto.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/dto.py @@ -57,7 +57,7 @@ class CompanyCreateDTO(BaseModel): ) # Configuration - logo: Optional[str] = Field(None, max_length=255, description="Company logo") + logo: Optional[str] = Field(None, max_length=512, description="Company logo (path local o clave S3)") has_express_line: Optional[bool] = Field(None, description="Has express line") order_format_type: Optional[str] = Field( None, max_length=19, description="Order format type" diff --git a/backend/api/v1/modules/a76/general_catalogs/company/models.py b/backend/api/v1/modules/a76/general_catalogs/company/models.py index 4914f791..739078ea 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/models.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/models.py @@ -60,7 +60,7 @@ class Company(Base, TimestampMixin): position: Mapped[Optional[str]] = mapped_column(String(30)) # Configuración básica - logo: Mapped[Optional[str]] = mapped_column(String(255)) + logo: Mapped[Optional[str]] = mapped_column(String(512)) has_express_line: Mapped[Optional[bool]] = mapped_column(Boolean, default=False, server_default="false") order_format_type: Mapped[Optional[str]] = mapped_column(String(19)) is_service_company: Mapped[Optional[bool]] = mapped_column(Boolean, default=False, server_default="false") diff --git a/backend/api/v1/modules/a76/general_catalogs/company/routes.py b/backend/api/v1/modules/a76/general_catalogs/company/routes.py index 5dff4d54..d6871d64 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/routes.py @@ -2,20 +2,23 @@ Rutas para gestión de empresa """ +import logging +import mimetypes import os import shutil from typing import List, Optional -import os -import shutil from pathlib import Path from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, status, File, UploadFile -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, Response from sqlalchemy.orm import Session +from core.config import settings from core.database import get_core_db -from core.security import get_current_user, validate_access_to_resource +from core.s3_keys import company_certificate_key, company_logo_key +from core.storage_s3 import delete_object_if_exists, get_object_bytes, put_object_bytes +from core.security import get_current_user, get_tenant_from_token, validate_access_to_resource from .....common.tenant_crud_routes import TenantCRUDRoutes from .dto import CompanyCreateDTO, CompanyResponseDTO, CompanyUpdateDTO from .models import Company @@ -26,6 +29,44 @@ UPLOAD_DIR = "uploads/companies" ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"} MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB +logger = logging.getLogger(__name__) + + +def _resolve_tenant_id_int(current_user: dict) -> int: + """Misma lógica que validate_access_to_resource: entero estable para BD y claves S3.""" + tid = get_tenant_from_token(current_user) + if tid is not None: + return int(tid) + raw = current_user.get("tenant_id") + if isinstance(raw, list) and raw: + raw = raw[0] + if raw is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Tenant ID not found in user data", + ) + try: + return int(raw) + except (TypeError, ValueError): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid tenant ID in token", + ) + + +def _is_s3_object_key(ref: Optional[str]) -> bool: + return bool(ref and ref.startswith("tenants/")) + + +def _remove_stored_file(ref: str) -> None: + if _is_s3_object_key(ref): + delete_object_if_exists(ref) + elif ref and os.path.isfile(ref): + try: + os.remove(ref) + except OSError: + pass + # Main router that includes base CRUD router = APIRouter(prefix="/company") @@ -219,14 +260,20 @@ async def get_company_logo_image( if not company or not company.logo: raise HTTPException(status_code=404, detail="Logo not found") + if _is_s3_object_key(company.logo): + try: + data = get_object_bytes(company.logo) + except Exception: + raise HTTPException(status_code=404, detail="Logo file not found on server") + media = mimetypes.guess_type(company.logo)[0] or "image/jpeg" + return Response(content=data, media_type=media) + file_path = Path(company.logo) if not file_path.exists(): - # Fallback for old paths or moved files - # Check if it exists in the 'standard' location even if DB thinks otherwise standard_path = Path(f"app_data/logos/{company_id}") / file_path.name if standard_path.exists(): return FileResponse(standard_path) - + raise HTTPException(status_code=404, detail="Logo file not found on server") return FileResponse(file_path) @@ -272,12 +319,7 @@ async def upload_company_logo( current_user: dict = Depends(get_current_user), ): """Upload a logo for a company""" - tenant_id = current_user.get("tenant_id") - if not tenant_id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Tenant ID not found in user data", - ) + tenant_id = _resolve_tenant_id_int(current_user) # Validar que la empresa existe company = CompanyService.get_by_id(db, company_id, tenant_id, 0) @@ -303,35 +345,34 @@ async def upload_company_logo( detail=f"File too large. Maximum size: {MAX_FILE_SIZE / 1024 / 1024}MB", ) - # Crear directorio si no existe - os.makedirs(UPLOAD_DIR, exist_ok=True) - - # Eliminar logo anterior si existe if company.logo: - old_logo_path = company.logo - if os.path.exists(old_logo_path): - try: - os.remove(old_logo_path) - except Exception: - pass # No es crítico si falla + _remove_stored_file(company.logo) - # Generar nombre único timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - filename = f"company_{company_id}_{timestamp}{file_ext}" - file_path = os.path.join(UPLOAD_DIR, filename) + filename = f"logo_{company_id}_{timestamp}{file_ext}" - # Guardar archivo try: - await file.seek(0) - with open(file_path, "wb") as buffer: - shutil.copyfileobj(file.file, buffer) + if settings.use_s3_object_storage: + ct = mimetypes.guess_type(filename)[0] or "image/jpeg" + key = company_logo_key(tenant_id, company_id, filename) + put_object_bytes(key, content, content_type=ct) + logger.info( + "Company logo stored in S3 key=%s bytes=%s", + key, + len(content), + ) + file_path = key + else: + os.makedirs(UPLOAD_DIR, exist_ok=True) + file_path = os.path.join(UPLOAD_DIR, filename) + with open(file_path, "wb") as f: + f.write(content) except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error saving file: {str(e)}", ) - # Actualizar la empresa con la ruta del logo update_data = CompanyUpdateDTO(logo=file_path) service = CompanyService(db) updated_company = service.update(db, company_id, tenant_id, 0, update_data) @@ -359,12 +400,7 @@ async def upload_company_certificate( Upload a certificate for a company certificate_type: fiel_cer, fiel_key, cfdi_cert_cer, cfdi_cert_key, cancel_cer, cancel_key """ - tenant_id = current_user.get("tenant_id") - if not tenant_id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Tenant ID not found in user data", - ) + tenant_id = _resolve_tenant_id_int(current_user) # Validar que la empresa existe service = CompanyService(db) @@ -410,27 +446,38 @@ async def upload_company_certificate( detail=f"File too large. Maximum size: {MAX_FILE_SIZE / 1024 / 1024}MB", ) - # Crear directorio si no existe - certs_dir = os.path.join(UPLOAD_DIR, str(company_id), "certificates") - os.makedirs(certs_dir, exist_ok=True) - - # Generar nombre único timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - filename = f"{certificate_type}_{timestamp}{file_ext}" - file_path = os.path.join(certs_dir, filename) - # Guardar archivo try: - await file.seek(0) - with open(file_path, "wb") as buffer: - shutil.copyfileobj(file.file, buffer) + if settings.use_s3_object_storage: + key = company_certificate_key( + tenant_id, company_id, certificate_type, timestamp, file_ext + ) + ct = ( + "application/x-x509-ca-cert" + if file_ext == ".cer" + else "application/pkcs8" + ) + put_object_bytes(key, content, content_type=ct) + logger.info( + "Company certificate stored in S3 key=%s bytes=%s", + key, + len(content), + ) + file_path = key + else: + certs_dir = os.path.join(UPLOAD_DIR, str(company_id), "certificates") + os.makedirs(certs_dir, exist_ok=True) + filename = f"{certificate_type}_{timestamp}{file_ext}" + file_path = os.path.join(certs_dir, filename) + with open(file_path, "wb") as f: + f.write(content) except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error saving file: {str(e)}", ) - # Actualizar la base de datos service.upload_certificate(company_id, certificate_type, file_path, tenant_id) return { diff --git a/backend/api/v1/modules/a76/general_catalogs/company/service.py b/backend/api/v1/modules/a76/general_catalogs/company/service.py index 37158c12..21502ebc 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/service.py @@ -678,6 +678,11 @@ class CompanyService: try: if target_cert: + old_path = getattr(target_cert, field_to_update, None) + if old_path and str(old_path).startswith("tenants/"): + from core.storage_s3 import delete_object_if_exists + + delete_object_if_exists(str(old_path)) # Si existe, actualizamos setattr(target_cert, field_to_update, file_path) else: diff --git a/backend/api/v1/modules/a76/layouts_csv/boms/routes.py b/backend/api/v1/modules/a76/layouts_csv/boms/routes.py index f7d46af7..d86f62cf 100644 --- a/backend/api/v1/modules/a76/layouts_csv/boms/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/boms/routes.py @@ -2,7 +2,6 @@ Rutas de importación CSV para BOMs. Flujo: upload → scan → status (polling) → commit. """ -import base64 import json import logging import os @@ -14,7 +13,6 @@ from typing import Dict, Any from core.celery_app import celery_app from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch @@ -22,10 +20,11 @@ from .schemas import ImportJobResponse from .tasks import ( scan_file, insert_valid_rows, - BOM_IMPORT_FILE_PREFIX, + JOB_TYPE, BOM_IMPORT_META_PREFIX, BOM_IMPORT_REDIS_TTL, ) +from ..common import storage as common_storage from ..common.error_csv import download_scan_errors_csv_stream from ..common.track_commit_dispatch import dispatch_tracked_layouts_csv_commit from ..common.responses import normalize_commit_status_payload @@ -67,31 +66,23 @@ async def upload_import_file( } try: - r = _get_redis() - r.set( - f"{BOM_IMPORT_FILE_PREFIX}{job_id}", - base64.b64encode(contents), - ex=BOM_IMPORT_REDIS_TTL, - ) - r.set( - f"{BOM_IMPORT_META_PREFIX}{job_id}", - json.dumps(meta_data).encode("utf-8"), - ex=BOM_IMPORT_REDIS_TTL, + common_storage.store_import_file( + JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=BOM_IMPORT_REDIS_TTL, + log_label="BOMs import", ) + except common_storage.ImportStoreError as e: + logger.error(f"BOMs import: store error: {e}") + raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") except Exception as e: logger.error(f"BOMs import: Redis store error: {e}") raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - with open(os.path.join(upload_dir, f"bom_{job_id}.csv"), "wb") as f: - f.write(contents) - with open(os.path.join(upload_dir, f"bom_{job_id}.meta.json"), "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning(f"BOMs import: local file save failed: {e}") - track_and_dispatch( db=db, task=scan_file, diff --git a/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/routes.py b/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/routes.py index d8612d2e..89147746 100644 --- a/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/routes.py @@ -2,7 +2,6 @@ Rutas de importación CSV para Cambio de régimen y Regularización (encabezado y partidas). Flujo: upload → scan → status (polling) → commit. Sin validaciones ni inserción aún. """ -import base64 import json import logging import os @@ -14,7 +13,6 @@ from typing import Literal, Optional, Dict, Any from core.celery_app import celery_app from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch @@ -65,7 +63,6 @@ async def upload_import_file( job_id = str(uuid4()) contents = await file.read() - file_key, meta_key, _ = common_storage.storage_keys(JOB_TYPE, job_id) meta_data = { "tenant_id": tenant_id, "company_id": company_id, @@ -76,25 +73,23 @@ async def upload_import_file( } try: - r = _get_redis() - r.set(file_key, base64.b64encode(contents), ex=CRREG_IMPORT_REDIS_TTL) - r.set(meta_key, json.dumps(meta_data).encode("utf-8"), ex=CRREG_IMPORT_REDIS_TTL) + common_storage.store_import_file( + JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=CRREG_IMPORT_REDIS_TTL, + log_label="Cambio régimen/Regularización import", + ) + except common_storage.ImportStoreError as e: + logger.error("Cambio régimen/Regularización import: store error: %s", e) + raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") except Exception as e: logger.error("Cambio régimen/Regularización import: Redis store error: %s", e) raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - csv_path = common_storage.file_path_for_job(JOB_TYPE, job_id) - with open(csv_path, "wb") as f: - f.write(contents) - meta_path = csv_path.replace(".csv", ".meta.json") - with open(meta_path, "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning("Cambio régimen/Regularización import: local file save failed: %s", e) - track_and_dispatch( db=db, task=scan_file, diff --git a/backend/api/v1/modules/a76/layouts_csv/classes/routes.py b/backend/api/v1/modules/a76/layouts_csv/classes/routes.py index 0d711a72..2fa3347b 100644 --- a/backend/api/v1/modules/a76/layouts_csv/classes/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/classes/routes.py @@ -2,7 +2,6 @@ Rutas de importación CSV para Clases de Materiales. Flujo: upload → scan → status (polling) → commit. """ -import base64 import json import logging import os @@ -14,7 +13,6 @@ from typing import Dict, Any from core.celery_app import celery_app from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch @@ -24,10 +22,11 @@ from .schemas import ImportJobResponse from .tasks import ( scan_file, insert_valid_rows, - CLS_IMPORT_FILE_PREFIX, + JOB_TYPE, CLS_IMPORT_META_PREFIX, CLS_IMPORT_REDIS_TTL, ) +from ..common import storage as common_storage from ..common.error_csv import download_scan_errors_csv_stream router = APIRouter() @@ -71,31 +70,23 @@ async def upload_import_file( } try: - r = _get_redis() - r.set( - f"{CLS_IMPORT_FILE_PREFIX}{job_id}", - base64.b64encode(contents), - ex=CLS_IMPORT_REDIS_TTL, - ) - r.set( - f"{CLS_IMPORT_META_PREFIX}{job_id}", - json.dumps(meta_data).encode("utf-8"), - ex=CLS_IMPORT_REDIS_TTL, + common_storage.store_import_file( + JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=CLS_IMPORT_REDIS_TTL, + log_label="Classes import", ) + except common_storage.ImportStoreError as e: + logger.error(f"Classes import: store error: {e}") + raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") except Exception as e: logger.error(f"Classes import: Redis store error: {e}") raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - with open(os.path.join(upload_dir, f"cls_{job_id}.csv"), "wb") as f: - f.write(contents) - with open(os.path.join(upload_dir, f"cls_{job_id}.meta.json"), "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning(f"Classes import: local file save failed: {e}") - track_and_dispatch( db=db, task=scan_file, diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/routes.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/routes.py index a9f14507..ef6453ec 100644 --- a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/routes.py @@ -2,7 +2,6 @@ Rutas de importación CSV para Clientes y Proveedores. Mismo flujo que a76.imports: upload → scan → status (polling) → commit. """ -import base64 import json import logging import os @@ -14,7 +13,6 @@ from typing import Dict, Any from core.celery_app import celery_app from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch @@ -24,10 +22,11 @@ from .schemas import ImportJobResponse from .tasks import ( scan_file, insert_valid_rows, - CP_IMPORT_FILE_PREFIX, + JOB_TYPE, CP_IMPORT_META_PREFIX, CP_IMPORT_REDIS_TTL, ) +from ..common import storage as common_storage from ..common.error_csv import download_scan_errors_csv_stream router = APIRouter() @@ -70,31 +69,23 @@ async def upload_import_file( } try: - r = _get_redis() - r.set( - f"{CP_IMPORT_FILE_PREFIX}{job_id}", - base64.b64encode(contents), - ex=CP_IMPORT_REDIS_TTL, - ) - r.set( - f"{CP_IMPORT_META_PREFIX}{job_id}", - json.dumps(meta_data).encode("utf-8"), - ex=CP_IMPORT_REDIS_TTL, + common_storage.store_import_file( + JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=CP_IMPORT_REDIS_TTL, + log_label="CP import", ) + except common_storage.ImportStoreError as e: + logger.error(f"CP import: store error: {e}") + raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") except Exception as e: logger.error(f"CP import: Redis store error: {e}") raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - with open(os.path.join(upload_dir, f"cp_{job_id}.csv"), "wb") as f: - f.write(contents) - with open(os.path.join(upload_dir, f"cp_{job_id}.meta.json"), "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning(f"CP import: local file save failed: {e}") - track_and_dispatch( db=db, task=scan_file, diff --git a/backend/api/v1/modules/a76/layouts_csv/common/storage.py b/backend/api/v1/modules/a76/layouts_csv/common/storage.py index f2875d5c..f737441a 100644 --- a/backend/api/v1/modules/a76/layouts_csv/common/storage.py +++ b/backend/api/v1/modules/a76/layouts_csv/common/storage.py @@ -15,6 +15,12 @@ logger = logging.getLogger(__name__) IMPORT_REDIS_TTL = 3600 +class ImportStoreError(Exception): + """Fallo al guardar CSV en Redis/MinIO.""" + + pass + + def _get_redis(): import redis url = os.getenv("VALKEY_URL", os.getenv("REDIS_URL", "redis://valkey:6379/0")) @@ -60,23 +66,103 @@ def error_path_for_job(job_type: str, job_id: str) -> str: return os.path.join(error_dir(), f"{job_type}_{job_id}.jsonl") +def store_import_file( + job_type: str, + job_id: str, + raw_bytes: bytes, + meta_dict: dict, + tenant_id, + company_id: int, + ttl: int = IMPORT_REDIS_TTL, + log_label: str = "", +) -> None: + """ + Guarda CSV y meta en Redis. Si CSV_IMPORT_STORAGE=minio, sube el CSV a S3 y en Redis + guarda JSON {"v":2,"s3_key":...}; si no, base64 en Redis (comportamiento anterior). + En modo redis opcionalmente escribe copia local bajo layouts/imports/temp (debug). + """ + from core.config import settings + + file_key, meta_key, _ = storage_keys(job_type, job_id) + r = _get_redis() + meta_bytes = json.dumps(meta_dict).encode("utf-8") + + if settings.CSV_IMPORT_STORAGE == "minio": + from core.storage_s3 import put_csv_object, s3_key_for_csv_import + + key = s3_key_for_csv_import(tenant_id, company_id, job_type, job_id) + try: + put_csv_object(key, raw_bytes) + except Exception as e: + logger.exception("%s MinIO put failed: %s", log_label or job_type, e) + raise ImportStoreError(str(e)) from e + payload = json.dumps({"v": 2, "s3_key": key}).encode("utf-8") + r.set(file_key, payload, ex=ttl) + else: + r.set(file_key, base64.b64encode(raw_bytes), ex=ttl) + + r.set(meta_key, meta_bytes, ex=ttl) + + if settings.CSV_IMPORT_STORAGE != "minio": + try: + path = file_path_for_job(job_type, job_id) + os.makedirs(upload_dir(), exist_ok=True) + with open(path, "wb") as f: + f.write(raw_bytes) + meta_path = path.replace(".csv", ".meta.json") + with open(meta_path, "w", encoding="utf-8") as f: + json.dump(meta_dict, f) + except Exception as e: + logger.warning("%s local file save failed: %s", log_label or job_type, e) + + def ensure_file_from_redis(job_type: str, job_id: str, log_prefix: str = "") -> Optional[str]: """ - Descarga contenido del CSV desde Redis y lo escribe en disco. + Obtiene el CSV desde Redis (referencia MinIO o base64 legacy) y lo escribe en disco. Devuelve la ruta del archivo o None si no hay datos o falla. """ + from core.config import settings + file_key, _, _ = storage_keys(job_type, job_id) r = _get_redis() data = r.get(file_key) if not data: return None + + path = file_path_for_job(job_type, job_id) + os.makedirs(upload_dir(), exist_ok=True) + + if data.startswith(b"{"): + try: + obj = json.loads(data.decode("utf-8")) + if isinstance(obj, dict) and obj.get("v") == 2 and obj.get("s3_key"): + if settings.CSV_IMPORT_STORAGE != "minio": + logger.warning( + "%s Redis has MinIO ref but CSV_IMPORT_STORAGE=%s", + log_prefix or job_type, + settings.CSV_IMPORT_STORAGE, + ) + from core.storage_s3 import get_object_bytes + + try: + raw = get_object_bytes(obj["s3_key"]) + except Exception as e: + logger.warning("%s MinIO get failed: %s", log_prefix or job_type, e) + return None + with open(path, "wb") as f: + f.write(raw) + return path + except json.JSONDecodeError: + pass + except Exception as e: + logger.warning("%s failed to parse MinIO ref from Redis: %s", log_prefix or job_type, e) + return None + try: raw = base64.b64decode(data) except Exception as e: logger.warning("%s failed to decode file from Redis: %s", log_prefix or job_type, e) return None - path = file_path_for_job(job_type, job_id) - os.makedirs(upload_dir(), exist_ok=True) with open(path, "wb") as f: f.write(raw) return path @@ -152,7 +238,31 @@ def cleanup_import_job( error_path: Optional[str] = None, meta_path: Optional[str] = None, ) -> None: - """Elimina archivos locales y claves Redis del job.""" + """Elimina archivos locales, objeto MinIO si aplica, y claves Redis del job.""" + from core.config import settings + + if settings.CSV_IMPORT_STORAGE == "minio": + from core.storage_s3 import delete_object_if_exists + from core.s3_keys import legacy_csv_import_key + + file_key, _, _ = storage_keys(job_type, job_id) + try: + r = _get_redis() + data = r.get(file_key) + deleted = False + if data and data.startswith(b"{"): + try: + obj = json.loads(data.decode("utf-8")) + if isinstance(obj, dict) and obj.get("v") == 2 and obj.get("s3_key"): + delete_object_if_exists(obj["s3_key"]) + deleted = True + except Exception as e: + logger.warning("Cleanup: parse Redis file ref: %s", e) + if not deleted: + delete_object_if_exists(legacy_csv_import_key(job_type, job_id)) + except Exception as e: + logger.warning("Cleanup: MinIO delete failed: %s", e) + if file_path and os.path.exists(file_path): try: os.remove(file_path) diff --git a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/routes.py b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/routes.py index dc17a477..36534ef0 100644 --- a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/routes.py @@ -2,7 +2,6 @@ Rutas de importación CSV para Agentes Aduanales. Mismo flujo que a76.imports: upload → scan → status (polling) → commit. """ -import base64 import json import logging import os @@ -14,7 +13,6 @@ from typing import Dict, Any from core.celery_app import celery_app from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch @@ -24,10 +22,11 @@ from .schemas import ImportJobResponse from .tasks import ( scan_file, insert_valid_rows, - CB_IMPORT_FILE_PREFIX, + JOB_TYPE, CB_IMPORT_META_PREFIX, CB_IMPORT_REDIS_TTL, ) +from ..common import storage as common_storage from ..common.error_csv import download_scan_errors_csv_stream router = APIRouter() @@ -70,31 +69,23 @@ async def upload_import_file( } try: - r = _get_redis() - r.set( - f"{CB_IMPORT_FILE_PREFIX}{job_id}", - base64.b64encode(contents), - ex=CB_IMPORT_REDIS_TTL, - ) - r.set( - f"{CB_IMPORT_META_PREFIX}{job_id}", - json.dumps(meta_data).encode("utf-8"), - ex=CB_IMPORT_REDIS_TTL, + common_storage.store_import_file( + JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=CB_IMPORT_REDIS_TTL, + log_label="CB import", ) + except common_storage.ImportStoreError as e: + logger.error(f"CB import: store error: {e}") + raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") except Exception as e: logger.error(f"CB import: Redis store error: {e}") raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - with open(os.path.join(upload_dir, f"cb_{job_id}.csv"), "wb") as f: - f.write(contents) - with open(os.path.join(upload_dir, f"cb_{job_id}.meta.json"), "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning(f"CB import: local file save failed: {e}") - track_and_dispatch( db=db, task=scan_file, diff --git a/backend/api/v1/modules/a76/layouts_csv/drivers/routes.py b/backend/api/v1/modules/a76/layouts_csv/drivers/routes.py index bb118f17..9d28625a 100644 --- a/backend/api/v1/modules/a76/layouts_csv/drivers/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/drivers/routes.py @@ -2,7 +2,6 @@ Rutas de importacion CSV para Conductores. Flujo: upload -> scan -> status (polling) -> commit. """ -import base64 import json import logging import os @@ -15,18 +14,17 @@ from typing import Dict, Any from core.celery_app import celery_app from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from .schemas import ImportJobResponse from .tasks import ( run_scan_sync, run_commit_sync, - DRV_IMPORT_FILE_PREFIX, - DRV_IMPORT_META_PREFIX, + JOB_TYPE, DRV_IMPORT_STATUS_PREFIX, DRV_IMPORT_REDIS_TTL, ) +from ..common import storage as common_storage from ..common.error_csv import download_scan_errors_csv_stream from ..common.responses import normalize_commit_status_payload @@ -67,31 +65,23 @@ async def upload_import_file( } try: - r = _get_redis() - r.set( - f"{DRV_IMPORT_FILE_PREFIX}{job_id}", - base64.b64encode(contents), - ex=DRV_IMPORT_REDIS_TTL, - ) - r.set( - f"{DRV_IMPORT_META_PREFIX}{job_id}", - json.dumps(meta_data).encode("utf-8"), - ex=DRV_IMPORT_REDIS_TTL, + common_storage.store_import_file( + JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=DRV_IMPORT_REDIS_TTL, + log_label="Drivers import", ) + except common_storage.ImportStoreError as e: + logger.error(f"Drivers import: store error: {e}") + raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") except Exception as e: logger.error(f"Drivers import: Redis store error: {e}") raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - with open(os.path.join(upload_dir, f"drv_{job_id}.csv"), "wb") as f: - f.write(contents) - with open(os.path.join(upload_dir, f"drv_{job_id}.meta.json"), "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning(f"Drivers import: local file save failed: {e}") - def run_scan_background(): try: run_scan_sync(job_id) diff --git a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/routes.py b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/routes.py index 4887092f..0ac58d66 100644 --- a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/routes.py @@ -2,7 +2,6 @@ Rutas de importación CSV para Tipos de Cambio. Mismo flujo que customs_brokers/imports: upload → scan → status (polling) → commit. """ -import base64 import json import logging import os @@ -14,7 +13,6 @@ from typing import Dict, Any, Optional from core.celery_app import celery_app from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch @@ -24,10 +22,11 @@ from .schemas import ImportJobResponse from .tasks import ( scan_file, insert_valid_rows, - ER_IMPORT_FILE_PREFIX, + JOB_TYPE, ER_IMPORT_META_PREFIX, ER_IMPORT_REDIS_TTL, ) +from ..common import storage as common_storage from ..common.error_csv import download_scan_errors_csv_stream router = APIRouter() @@ -81,31 +80,23 @@ async def upload_import_file( } try: - r = _get_redis() - r.set( - f"{ER_IMPORT_FILE_PREFIX}{job_id}", - base64.b64encode(contents), - ex=ER_IMPORT_REDIS_TTL, - ) - r.set( - f"{ER_IMPORT_META_PREFIX}{job_id}", - json.dumps(meta_data).encode("utf-8"), - ex=ER_IMPORT_REDIS_TTL, + common_storage.store_import_file( + JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=ER_IMPORT_REDIS_TTL, + log_label="ER import", ) + except common_storage.ImportStoreError as e: + logger.error(f"ER import: store error: {e}") + raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") except Exception as e: logger.error(f"ER import: Redis store error: {e}") raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - with open(os.path.join(upload_dir, f"er_{job_id}.csv"), "wb") as f: - f.write(contents) - with open(os.path.join(upload_dir, f"er_{job_id}.meta.json"), "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning(f"ER import: local file save failed: {e}") - track_and_dispatch( db=db, task=scan_file, diff --git a/backend/api/v1/modules/a76/layouts_csv/exportacion/routes.py b/backend/api/v1/modules/a76/layouts_csv/exportacion/routes.py index 49eba2b8..ec4f7fc9 100644 --- a/backend/api/v1/modules/a76/layouts_csv/exportacion/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/exportacion/routes.py @@ -2,7 +2,6 @@ Rutas de importación CSV para Exportación (encabezado y partidas). Flujo: upload → scan → status (polling) → commit. Sin validaciones ni inserción aún. """ -import base64 import json import logging import os @@ -17,7 +16,6 @@ from typing import Literal, Optional, Dict, Any from core.celery_app import celery_app from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch @@ -61,7 +59,6 @@ async def upload_import_file( job_id = str(uuid4()) contents = await file.read() - file_key, meta_key, _ = common_storage.storage_keys(JOB_TYPE, job_id) default_template = ( "exp_def_header" if model_target == "invoice_header" else "exp_def_series" if model_target == "invoice_series" @@ -83,25 +80,23 @@ async def upload_import_file( } try: - r = _get_redis() - r.set(file_key, base64.b64encode(contents), ex=EXP_IMPORT_REDIS_TTL) - r.set(meta_key, json.dumps(meta_data).encode("utf-8"), ex=EXP_IMPORT_REDIS_TTL) + common_storage.store_import_file( + JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=EXP_IMPORT_REDIS_TTL, + log_label="Exportación import", + ) + except common_storage.ImportStoreError as e: + logger.error("Exportación import: store error: %s", e) + raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") except Exception as e: logger.error("Exportación import: Redis store error: %s", e) raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - csv_path = common_storage.file_path_for_job(JOB_TYPE, job_id) - with open(csv_path, "wb") as f: - f.write(contents) - meta_path = csv_path.replace(".csv", ".meta.json") - with open(meta_path, "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning("Exportación import: local file save failed: %s", e) - track_and_dispatch( db=db, task=scan_file, diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py b/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py index 0ae9556f..8cb0df25 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py @@ -1,6 +1,5 @@ from datetime import datetime from uuid import uuid4 -import base64 import os import json import logging @@ -14,14 +13,13 @@ from typing import Optional, Literal, Dict, Any from core.celery_app import celery_app from core.config import settings from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch from .tasks import ( scan_file, insert_valid_rows, - IMPORT_FILE_KEY_PREFIX, + JOB_TYPE, IMPORT_META_KEY_PREFIX, IMPORT_REDIS_TTL, ) @@ -83,37 +81,25 @@ async def upload_import_file( "template_id": template_id, } - # Store file and meta in Redis so the Celery worker can read them (no shared filesystem needed) try: - redis_client = _get_redis() - redis_client.set( - f"{IMPORT_FILE_KEY_PREFIX}{job_id}", - base64.b64encode(contents), - ex=IMPORT_REDIS_TTL, - ) - redis_client.set( - f"{IMPORT_META_KEY_PREFIX}{job_id}", - json.dumps(meta_data).encode("utf-8"), - ex=IMPORT_REDIS_TTL, + common_storage.store_import_file( + JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=IMPORT_REDIS_TTL, + log_label="Facturas import", ) + except common_storage.ImportStoreError as e: + logger.error(f"Import store error: {e}") + raise HTTPException(status_code=500, detail="Failed to queue file for processing.") except Exception as e: logger.error(f"Redis store error: {e}") raise HTTPException(status_code=500, detail="Failed to queue file for processing.") - # Optional: also write to local disk (e.g. for same-machine worker or debugging) - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - file_path = os.path.join(upload_dir, f"{job_id}.csv") - meta_path = os.path.join(upload_dir, f"{job_id}.meta.json") - with open(file_path, "wb") as f: - f.write(contents) - with open(meta_path, "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning(f"Local file save failed (worker will use Redis): {e}") - - # Trigger Celery Task (Async). Worker loads file from Redis. + # Trigger Celery Task (Async). Worker loads file from Redis / MinIO. track_and_dispatch( db=db, task=scan_file, diff --git a/backend/api/v1/modules/a76/layouts_csv/parts/routes.py b/backend/api/v1/modules/a76/layouts_csv/parts/routes.py index 6e1fb792..2c479dfb 100644 --- a/backend/api/v1/modules/a76/layouts_csv/parts/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/parts/routes.py @@ -2,7 +2,6 @@ Rutas de importación CSV para Números de Parte. Flujo: upload → scan → status (polling) → commit. """ -import base64 import json import logging import os @@ -14,7 +13,6 @@ from typing import Dict, Any from core.celery_app import celery_app from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch @@ -22,10 +20,11 @@ from .schemas import ImportJobResponse from .tasks import ( scan_file, insert_valid_rows, - PART_IMPORT_FILE_PREFIX, + JOB_TYPE, PART_IMPORT_META_PREFIX, PART_IMPORT_REDIS_TTL, ) +from ..common import storage as common_storage from ..common.error_csv import download_scan_errors_csv_stream from ..common.track_commit_dispatch import dispatch_tracked_layouts_csv_commit from ..common.responses import normalize_commit_status_payload @@ -71,31 +70,23 @@ async def upload_import_file( } try: - r = _get_redis() - r.set( - f"{PART_IMPORT_FILE_PREFIX}{job_id}", - base64.b64encode(contents), - ex=PART_IMPORT_REDIS_TTL, - ) - r.set( - f"{PART_IMPORT_META_PREFIX}{job_id}", - json.dumps(meta_data).encode("utf-8"), - ex=PART_IMPORT_REDIS_TTL, + common_storage.store_import_file( + JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=PART_IMPORT_REDIS_TTL, + log_label="Parts import", ) + except common_storage.ImportStoreError as e: + logger.error(f"Parts import: store error: {e}") + raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") except Exception as e: logger.error(f"Parts import: Redis store error: {e}") raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - with open(os.path.join(upload_dir, f"part_{job_id}.csv"), "wb") as f: - f.write(contents) - with open(os.path.join(upload_dir, f"part_{job_id}.meta.json"), "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning(f"Parts import: local file save failed: {e}") - track_and_dispatch( db=db, task=scan_file, diff --git a/backend/api/v1/modules/a76/layouts_csv/pedmientos/routes.py b/backend/api/v1/modules/a76/layouts_csv/pedmientos/routes.py index 69e26d65..0031f9de 100644 --- a/backend/api/v1/modules/a76/layouts_csv/pedmientos/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/pedmientos/routes.py @@ -2,7 +2,6 @@ Rutas de importación CSV para Pedimentos. Mismo flujo que customs_brokers/imports: upload → scan → status (polling) → commit. """ -import base64 import json import logging import os @@ -14,7 +13,6 @@ from typing import Dict, Any, Optional from core.celery_app import celery_app from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch @@ -64,7 +62,6 @@ async def upload_import_file( job_id = str(uuid4()) contents = await file.read() - file_key, meta_key, _ = common_storage.storage_keys(PED_JOB_TYPE, job_id) meta_data: Dict[str, Any] = { "tenant_id": tenant_id, "company_id": company_id, @@ -76,33 +73,23 @@ async def upload_import_file( meta_data["dateFormat"] = dateFormat try: - r = _get_redis() - r.set( - file_key, - base64.b64encode(contents), - ex=PED_IMPORT_REDIS_TTL, - ) - r.set( - meta_key, - json.dumps(meta_data).encode("utf-8"), - ex=PED_IMPORT_REDIS_TTL, + common_storage.store_import_file( + PED_JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=PED_IMPORT_REDIS_TTL, + log_label="Pedimentos import", ) + except common_storage.ImportStoreError as e: + logger.error(f"Pedimentos import: store error: {e}") + raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") except Exception as e: logger.error(f"Pedimentos import: Redis store error: {e}") raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - csv_path = common_storage.file_path_for_job(PED_JOB_TYPE, job_id) - with open(csv_path, "wb") as f: - f.write(contents) - meta_path = csv_path.replace(".csv", ".meta.json") - with open(meta_path, "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning(f"Pedimentos import: local file save failed: {e}") - track_and_dispatch( db=db, task=scan_file, diff --git a/backend/api/v1/modules/a76/layouts_csv/trailers/routes.py b/backend/api/v1/modules/a76/layouts_csv/trailers/routes.py index 73547beb..fc46a9af 100644 --- a/backend/api/v1/modules/a76/layouts_csv/trailers/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/trailers/routes.py @@ -2,7 +2,6 @@ Rutas de importación CSV para Trailers y Cajas. Flujo: upload -> scan -> status (polling) -> commit. """ -import base64 import json import logging import os @@ -15,7 +14,6 @@ from typing import Dict, Any from core.celery_app import celery_app from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch @@ -24,11 +22,11 @@ from .tasks import ( scan_file, run_scan_sync, run_commit_sync, - TRL_IMPORT_FILE_PREFIX, - TRL_IMPORT_META_PREFIX, + JOB_TYPE, TRL_IMPORT_STATUS_PREFIX, TRL_IMPORT_REDIS_TTL, ) +from ..common import storage as common_storage from ..common.error_csv import download_scan_errors_csv_stream from ..common.responses import normalize_commit_status_payload @@ -71,31 +69,23 @@ async def upload_import_file( } try: - r = _get_redis() - r.set( - f"{TRL_IMPORT_FILE_PREFIX}{job_id}", - base64.b64encode(contents), - ex=TRL_IMPORT_REDIS_TTL, - ) - r.set( - f"{TRL_IMPORT_META_PREFIX}{job_id}", - json.dumps(meta_data).encode("utf-8"), - ex=TRL_IMPORT_REDIS_TTL, + common_storage.store_import_file( + JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=TRL_IMPORT_REDIS_TTL, + log_label="Trailers import", ) + except common_storage.ImportStoreError as e: + logger.error(f"Trailers import: store error: {e}") + raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") except Exception as e: logger.error(f"Trailers import: Redis store error: {e}") raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - with open(os.path.join(upload_dir, f"trl_{job_id}.csv"), "wb") as f: - f.write(contents) - with open(os.path.join(upload_dir, f"trl_{job_id}.meta.json"), "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning(f"Trailers import: local file save failed: {e}") - track_and_dispatch( db=db, task=scan_file, diff --git a/backend/api/v1/modules/a76/layouts_csv/transportistas/routes.py b/backend/api/v1/modules/a76/layouts_csv/transportistas/routes.py index 518b3d3f..4191393b 100644 --- a/backend/api/v1/modules/a76/layouts_csv/transportistas/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/transportistas/routes.py @@ -2,7 +2,6 @@ Rutas de importación CSV para Transportistas. Flujo: upload → scan → status (polling) → commit. """ -import base64 import json import logging import os @@ -15,7 +14,6 @@ from typing import Dict, Any from core.celery_app import celery_app from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch @@ -24,11 +22,11 @@ from .tasks import ( scan_file, run_scan_sync, run_commit_sync, - TRP_IMPORT_FILE_PREFIX, - TRP_IMPORT_META_PREFIX, + JOB_TYPE, TRP_IMPORT_STATUS_PREFIX, TRP_IMPORT_REDIS_TTL, ) +from ..common import storage as common_storage from ..common.error_csv import download_scan_errors_csv_stream from ..common.responses import normalize_commit_status_payload @@ -71,31 +69,23 @@ async def upload_import_file( } try: - r = _get_redis() - r.set( - f"{TRP_IMPORT_FILE_PREFIX}{job_id}", - base64.b64encode(contents), - ex=TRP_IMPORT_REDIS_TTL, - ) - r.set( - f"{TRP_IMPORT_META_PREFIX}{job_id}", - json.dumps(meta_data).encode("utf-8"), - ex=TRP_IMPORT_REDIS_TTL, + common_storage.store_import_file( + JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=TRP_IMPORT_REDIS_TTL, + log_label="Transportistas import", ) + except common_storage.ImportStoreError as e: + logger.error("Transportistas import: store error: %s", e) + raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") except Exception as e: logger.error("Transportistas import: Redis store error: %s", e) raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - with open(os.path.join(upload_dir, f"trp_{job_id}.csv"), "wb") as f: - f.write(contents) - with open(os.path.join(upload_dir, f"trp_{job_id}.meta.json"), "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning("Transportistas import: local file save failed: %s", e) - track_and_dispatch( db=db, task=scan_file, diff --git a/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/routes.py b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/routes.py index b35846f5..4e641f17 100644 --- a/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/routes.py @@ -2,7 +2,6 @@ Rutas de importación CSV para Fracción Americana (US Tariff Fractions). Mismo flujo que exchange_rate/imports: upload → scan → status (polling) → commit. """ -import base64 import json import logging import os @@ -14,7 +13,6 @@ from typing import Dict, Any from core.celery_app import celery_app from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch @@ -24,10 +22,11 @@ from .schemas import ImportJobResponse from .tasks import ( scan_file, insert_valid_rows, - FA_IMPORT_FILE_PREFIX, + JOB_TYPE, FA_IMPORT_META_PREFIX, FA_IMPORT_REDIS_TTL, ) +from ..common import storage as common_storage from ..common.error_csv import download_scan_errors_csv_stream router = APIRouter() @@ -73,31 +72,23 @@ async def upload_import_file( } try: - r = _get_redis() - r.set( - f"{FA_IMPORT_FILE_PREFIX}{job_id}", - base64.b64encode(contents), - ex=FA_IMPORT_REDIS_TTL, - ) - r.set( - f"{FA_IMPORT_META_PREFIX}{job_id}", - json.dumps(meta_data).encode("utf-8"), - ex=FA_IMPORT_REDIS_TTL, + common_storage.store_import_file( + JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=FA_IMPORT_REDIS_TTL, + log_label="FA import", ) + except common_storage.ImportStoreError as e: + logger.error(f"FA import: store error: {e}") + raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") except Exception as e: logger.error(f"FA import: Redis store error: {e}") raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - with open(os.path.join(upload_dir, f"fa_{job_id}.csv"), "wb") as f: - f.write(contents) - with open(os.path.join(upload_dir, f"fa_{job_id}.meta.json"), "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning(f"FA import: local file save failed: {e}") - track_and_dispatch( db=db, task=scan_file, diff --git a/backend/api/v1/modules/a76/layouts_csv/vehicles/routes.py b/backend/api/v1/modules/a76/layouts_csv/vehicles/routes.py index 95539a68..aff5f682 100644 --- a/backend/api/v1/modules/a76/layouts_csv/vehicles/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/vehicles/routes.py @@ -2,7 +2,6 @@ Rutas de importación CSV para Vehículos (Transportes). Flujo: upload → scan → status (polling) → commit. """ -import base64 import json import logging import os @@ -15,7 +14,6 @@ from typing import Dict, Any from core.celery_app import celery_app from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch @@ -24,11 +22,11 @@ from .tasks import ( scan_file, run_scan_sync, run_commit_sync, - VEHL_IMPORT_FILE_PREFIX, - VEHL_IMPORT_META_PREFIX, + JOB_TYPE, VEHL_IMPORT_STATUS_PREFIX, VEHL_IMPORT_REDIS_TTL, ) +from ..common import storage as common_storage from ..common.error_csv import download_scan_errors_csv_stream from ..common.responses import normalize_commit_status_payload @@ -71,31 +69,23 @@ async def upload_import_file( } try: - r = _get_redis() - r.set( - f"{VEHL_IMPORT_FILE_PREFIX}{job_id}", - base64.b64encode(contents), - ex=VEHL_IMPORT_REDIS_TTL, - ) - r.set( - f"{VEHL_IMPORT_META_PREFIX}{job_id}", - json.dumps(meta_data).encode("utf-8"), - ex=VEHL_IMPORT_REDIS_TTL, + common_storage.store_import_file( + JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=VEHL_IMPORT_REDIS_TTL, + log_label="Vehicles import", ) + except common_storage.ImportStoreError as e: + logger.error(f"Vehicles import: store error: {e}") + raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") except Exception as e: logger.error(f"Vehicles import: Redis store error: {e}") raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - with open(os.path.join(upload_dir, f"veh_{job_id}.csv"), "wb") as f: - f.write(contents) - with open(os.path.join(upload_dir, f"veh_{job_id}.meta.json"), "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning(f"Vehicles import: local file save failed: {e}") - track_and_dispatch( db=db, task=scan_file, diff --git a/backend/api/v1/modules/core/help_center/routes.py b/backend/api/v1/modules/core/help_center/routes.py index 29f576b5..c76d697a 100644 --- a/backend/api/v1/modules/core/help_center/routes.py +++ b/backend/api/v1/modules/core/help_center/routes.py @@ -1,13 +1,24 @@ -import shutil +import mimetypes import os +import shutil import uuid from datetime import datetime from typing import List, Optional, Dict, Any from uuid import UUID + from fastapi import APIRouter, Depends, HTTPException, Header, status, UploadFile, File +from fastapi.responses import Response from sqlalchemy.orm import Session -from core.database import get_core_db + from core.config import settings +from core.database import get_core_db +from core.s3_keys import ( + help_asset_key, + help_public_api_path, + help_s3_key_to_public_relative_path, + system_help_object_key, +) +from core.storage_s3 import get_object_bytes, put_object_bytes from core.security import get_current_user, has_role from .schemas import HelpArticleInDB, HelpArticleCreate, HelpArticleUpdate, HelpSyncRequest, HelpSyncResponse from .services import HelpCenterService @@ -80,60 +91,95 @@ def sync_help_article(sync_data: HelpSyncRequest, db: Session = Depends(get_core return result +@router.get("/files/{file_path:path}") +def serve_help_file(file_path: str): + """Sirve un objeto bajo system/help/ (público vía middleware).""" + if ".." in file_path or file_path.startswith("/"): + raise HTTPException(status_code=404, detail="Not found") + try: + key = system_help_object_key(file_path) + except ValueError: + raise HTTPException(status_code=404, detail="Not found") + if not settings.use_s3_object_storage: + legacy = os.path.join("uploads", "help", file_path) + if not os.path.isfile(legacy): + raise HTTPException(status_code=404, detail="Not found") + with open(legacy, "rb") as f: + data = f.read() + media = mimetypes.guess_type(file_path)[0] or "application/octet-stream" + return Response(content=data, media_type=media) + try: + data = get_object_bytes(key) + except Exception: + raise HTTPException(status_code=404, detail="Not found") + media = mimetypes.guess_type(file_path)[0] or "application/octet-stream" + return Response(content=data, media_type=media) + + @router.post("/upload-image/") -def upload_help_image( +async def upload_help_image( file: UploadFile = File(...), current_user: Dict[str, Any] = Depends(has_role("admin")) ): """Sube una imagen para usar en los artículos.""" try: - file_ext = os.path.splitext(file.filename)[1] + file_ext = os.path.splitext(file.filename or "")[1] or ".png" new_filename = f"{uuid.uuid4()}{file_ext}" - file_location = f"uploads/help/{new_filename}" - - # Ensure directory exists + body = await file.read() + if settings.use_s3_object_storage: + key = help_asset_key("", new_filename) + ct = mimetypes.guess_type(new_filename)[0] or "image/png" + put_object_bytes(key, body, content_type=ct) + rel = help_s3_key_to_public_relative_path(key) + return {"url": help_public_api_path(rel)} os.makedirs("uploads/help", exist_ok=True) - - with open(file_location, "wb+") as buffer: - shutil.copyfileobj(file.file, buffer) - + file_location = f"uploads/help/{new_filename}" + with open(file_location, "wb") as f: + f.write(body) return {"url": f"/api/uploads/help/{new_filename}"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.post("/upload-asset/") -def upload_help_asset( +async def upload_help_asset( file: UploadFile = File(...), current_user: Dict[str, Any] = Depends(has_role("admin")) ): """Sube cualquier tipo de archivo (PDF, Video, etc.) para la biblioteca.""" try: - file_ext = os.path.splitext(file.filename)[1].lower() + file_ext = os.path.splitext(file.filename or "")[1].lower() new_filename = f"{uuid.uuid4()}{file_ext}" - - # Guardar en una carpeta segun el tipo o general - folder = "uploads/help/assets" - if file_ext in ['.pdf']: - folder = "uploads/help/pdfs" - elif file_ext in ['.mp4', '.mov', '.avi']: - folder = "uploads/help/videos" - - file_location = f"{folder}/{new_filename}" - - # Ensure directory exists + subfolder = "assets" + if file_ext == ".pdf": + subfolder = "pdfs" + elif file_ext in [".mp4", ".mov", ".avi"]: + subfolder = "videos" + + if settings.use_s3_object_storage: + body = await file.read() + key = help_asset_key(subfolder, new_filename) + ct = file.content_type or mimetypes.guess_type(new_filename)[0] or "application/octet-stream" + put_object_bytes(key, body, content_type=ct) + rel = help_s3_key_to_public_relative_path(key) + return { + "url": help_public_api_path(rel), + "filename": file.filename, + "size": len(body), + "mime_type": file.content_type, + } + + folder = f"uploads/help/{subfolder}" os.makedirs(folder, exist_ok=True) - - with open(file_location, "wb+") as buffer: - shutil.copyfileobj(file.file, buffer) - - # Get file size + file_location = f"{folder}/{new_filename}" + body = await file.read() + with open(file_location, "wb") as f: + f.write(body) file_size = os.path.getsize(file_location) - return { "url": f"/api/{file_location}", "filename": file.filename, "size": file_size, - "mime_type": file.content_type + "mime_type": file.content_type, } except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/api/v1/modules/core/help_center/utils.py b/backend/api/v1/modules/core/help_center/utils.py index b5fe4c58..8d9b7854 100644 --- a/backend/api/v1/modules/core/help_center/utils.py +++ b/backend/api/v1/modules/core/help_center/utils.py @@ -1,76 +1,111 @@ +import logging +import mimetypes import os import re -import httpx -import logging -import uuid from pathlib import Path +from typing import Optional + +import httpx + from core.config import settings +from core.s3_keys import SYSTEM_HELP_PREFIX +from core.storage_s3 import object_exists, put_object_bytes logger = logging.getLogger(__name__) + +def _asset_url_to_s3_key(asset_url: str) -> Optional[str]: + """Deriva la clave S3 bajo system/help/ a partir de una URL de artículo.""" + if "/help-center/files/" in asset_url: + rel = asset_url.split("/help-center/files/", 1)[1].lstrip("/") + if ".." in rel: + return None + return f"{SYSTEM_HELP_PREFIX}{rel}" + u = asset_url.replace("/api/uploads/", "uploads/") + if u.startswith("/"): + u = u[1:] + if u.startswith("uploads/help/"): + return f"{SYSTEM_HELP_PREFIX}{u[len('uploads/help/') :]}" + return None + + def download_file_from_hub(relative_path: str) -> bool: """ - Downloads a file from the Hub to the local storage. - relative_path: e.g., 'uploads/help/pdfs/myfile.pdf' or '/api/uploads/help/image.png' + Descarga un asset del Hub y lo guarda en MinIO (system/help/...) o en disco si no hay almacenamiento S3 activo. + relative_path: URL parcial, p. ej. '/api/uploads/help/x.png' o '/api/v1/core/help-center/files/pdfs/x.pdf' """ if not settings.CENTRAL_SERVER_URL or settings.CENTRAL_SERVER_URL == '""': return False - # Clean the path - clean_path = relative_path.replace("/api/uploads/", "uploads/") - if clean_path.startswith("/"): - clean_path = clean_path[1:] - - # Check if it starts with uploads - if not clean_path.startswith("uploads/"): - # If it doesn't start with uploads, it might just be the filename or a subpath - # We assume it's relative to /app/ - pass + key = _asset_url_to_s3_key(relative_path) + if not key: + logger.warning("download_file_from_hub: could not map URL to S3 key: %s", relative_path) + return False - local_path = Path(clean_path) - if local_path.exists(): - logger.info(f"File {clean_path} already exists, skipping download.") + if settings.use_s3_object_storage and object_exists(key): + logger.info("S3 object %s already exists, skipping download.", key) return True - # Ensure directories exist - local_path.parent.mkdir(parents=True, exist_ok=True) - - # Resolve Hub Base URL - # CENTRAL_SERVER_URL is usually http://hub:8000/api/v1/core/help-center/sync/ - # We want http://hub:8000/api/ base_url = settings.CENTRAL_SERVER_URL.split("/v1/")[0] - # The file in backend is served usually under /api/uploads/... - # But clean_path is just "uploads/...". So the Hub route is base_url + "/" + clean_path - hub_file_url = f"{base_url}/{clean_path}" + if "/help-center/files/" in relative_path: + rel = relative_path.split("/help-center/files/", 1)[1].lstrip("/") + hub_file_url = f"{base_url.rstrip('/')}/api/v1/core/help-center/files/{rel}" + else: + clean_path = relative_path.replace("/api/uploads/", "uploads/") + if clean_path.startswith("/"): + clean_path = clean_path[1:] + hub_file_url = f"{base_url.rstrip('/')}/{clean_path}" + + logger.info("Downloading asset from Hub: %s", hub_file_url) - logger.info(f"Downloading asset from Hub: {hub_file_url} -> {local_path}") - try: with httpx.Client() as client: response = client.get(hub_file_url, timeout=30.0) - if response.status_code == 200: - with open(local_path, "wb") as f: - f.write(response.content) - logger.info(f"Successfully downloaded {clean_path}") - return True - else: - logger.warning(f"Failed to download {clean_path}: Status {response.status_code} URL: {hub_file_url}") + if response.status_code != 200: + logger.warning( + "Failed to download %s: Status %s URL: %s", + relative_path, + response.status_code, + hub_file_url, + ) return False + body = response.content except Exception as e: - logger.error(f"Error downloading {clean_path}: {str(e)}") + logger.error("Error downloading %s: %s", relative_path, str(e)) return False + if settings.use_s3_object_storage: + rel = key[len(SYSTEM_HELP_PREFIX) :] + ct = mimetypes.guess_type(rel)[0] or "application/octet-stream" + try: + put_object_bytes(key, body, content_type=ct) + logger.info("Stored hub asset in S3: %s", key) + return True + except Exception as e: + logger.error("S3 put failed for %s: %s", key, e) + return False + + rel = key[len(SYSTEM_HELP_PREFIX) :] + local_path = Path("uploads/help") / rel + local_path.parent.mkdir(parents=True, exist_ok=True) + local_path.write_bytes(body) + logger.info("Stored hub asset locally: %s", local_path) + return True + + def sync_assets_from_content(content: str): - """ - Parses markdown content for image URLs and downloads them if they are local references. - Example: ![alt text](/api/uploads/help/uuid.png) - """ + """Parsea markdown y descarga imágenes referenciadas (rutas legacy y nuevas).""" if not content: return - # Regex for markdown images: ![...](/api/uploads/...) - image_pattern = r'!\[.*?\]\((/api/uploads/.*?)\)' - matches = re.findall(image_pattern, content) - - for asset_url in matches: - download_file_from_hub(asset_url) + patterns = [ + r'!\[.*?\]\((/api/uploads/.*?)\)', + r'!\[.*?\]\((/api/v1/core/help-center/files/.*?)\)', + ] + seen = set() + for pattern in patterns: + for asset_url in re.findall(pattern, content): + if asset_url in seen: + continue + seen.add(asset_url) + download_file_from_hub(asset_url) diff --git a/backend/api/v1/modules/core/users/routes.py b/backend/api/v1/modules/core/users/routes.py index 594355f5..6ea6893a 100644 --- a/backend/api/v1/modules/core/users/routes.py +++ b/backend/api/v1/modules/core/users/routes.py @@ -2,14 +2,20 @@ Rutas para gestión de usuarios de Keycloak """ +import logging +import mimetypes from typing import Optional import os import uuid from pathlib import Path +from core.config import settings from core.database import get_core_db +from core.s3_keys import public_user_avatar_api_path, user_avatar_key +from core.storage_s3 import delete_object_if_exists, get_object_bytes, put_object_bytes from core.security import get_current_user, validate_access_to_resource from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile +from fastapi.responses import Response from sqlalchemy.orm import Session from ..user_tenant.models import UserTenant @@ -25,6 +31,10 @@ from .service import UserService router = APIRouter(prefix="/users", tags=["Users"]) +logger = logging.getLogger(__name__) + +_AVATAR_EXT = {".jpg", ".jpeg", ".png", ".gif", ".webp"} + @router.get("/stats", response_model=UserStatsDTO) def get_user_statistics( @@ -67,6 +77,52 @@ def list_users( return result +@router.get("/avatar/{tenant_id}/{keycloak_user_id}") +def get_user_avatar_image( + tenant_id: int, + keycloak_user_id: str, + db: Session = Depends(get_core_db), +): + """ + Sirve la imagen de avatar (público para poder usarla en sin Bearer). + El almacenamiento interno puede ser clave S3 o ruta bajo uploads/. + """ + ut = ( + db.query(UserTenant) + .filter( + UserTenant.tenant_id == tenant_id, + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.is_active == True, + ) + .first() + ) + if not ut or not ut.avatar_url: + raise HTTPException(status_code=404, detail="Avatar not found") + + raw = ut.avatar_url + if raw.startswith("tenants/"): + try: + data = get_object_bytes(raw) + except Exception: + raise HTTPException(status_code=404, detail="Avatar not found") + media = mimetypes.guess_type(raw)[0] or "image/jpeg" + return Response(content=data, media_type=media) + + rel = raw.lstrip("/") + path = Path(rel) + if not path.is_file(): + path = Path.cwd() / rel + if not path.is_file(): + alt = Path("/app") / rel + if alt.is_file(): + path = alt + if not path.is_file(): + raise HTTPException(status_code=404, detail="Avatar file not found") + data = path.read_bytes() + media = mimetypes.guess_type(str(path))[0] or "image/jpeg" + return Response(content=data, media_type=media) + + @router.get("/{user_id}", response_model=UserResponseDTO) def get_user( user_id: str, @@ -282,33 +338,74 @@ async def upload_avatar( db: Session = Depends(get_core_db), ): """ - Sube un avatar para el usuario actual - Retorna la URL del avatar subido + Sube un avatar para el usuario actual. + Con MinIO guarda en tenants/{tid}/users/{sub}/avatar.{ext} y persiste la clave en UserTenant. + Retorna URL pública para (GET /users/avatar/...). """ - # Validar tipo de archivo if not file.content_type or not file.content_type.startswith("image/"): raise HTTPException(status_code=400, detail="El archivo debe ser una imagen") - # Validar tamaño (max 2MB) + keycloak_user_id = current_user.get("sub") + if not keycloak_user_id: + raise HTTPException(status_code=400, detail="User ID not found in token") + + user_tenant = ( + db.query(UserTenant) + .filter( + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.is_active == True, + ) + .first() + ) + if not user_tenant: + raise HTTPException( + status_code=400, detail="User does not belong to any tenant" + ) + + ext = Path(file.filename or "image.jpg").suffix.lower() or ".jpg" + if ext not in _AVATAR_EXT: + raise HTTPException( + status_code=400, + detail=f"Extensión no permitida. Use: {', '.join(sorted(_AVATAR_EXT))}", + ) + contents = await file.read() if len(contents) > 2 * 1024 * 1024: raise HTTPException(status_code=400, detail="La imagen debe ser menor a 2MB") - # Crear directorio si no existe - upload_dir = Path("/app/uploads/avatars") - upload_dir.mkdir(parents=True, exist_ok=True) + tenant_id = user_tenant.tenant_id - # Generar nombre con keycloak_user_id (sobrescribe si existe) - keycloak_user_id = current_user.get("sub") - ext = Path(file.filename or "image.jpg").suffix - filename = f"{keycloak_user_id}{ext}" - file_path = upload_dir / filename + try: + if settings.use_s3_object_storage: + if user_tenant.avatar_url and str(user_tenant.avatar_url).startswith( + "tenants/" + ): + delete_object_if_exists(str(user_tenant.avatar_url)) + key = user_avatar_key(tenant_id, keycloak_user_id, ext) + ct = file.content_type or mimetypes.guess_type(f"x{ext}")[0] or "image/jpeg" + put_object_bytes(key, contents, content_type=ct) + user_tenant.avatar_url = key + logger.info( + "User avatar stored in S3 key=%s bytes=%s", key, len(contents) + ) + else: + upload_dir = Path("uploads/avatars") + upload_dir.mkdir(parents=True, exist_ok=True) + filename = f"{keycloak_user_id}{ext}" + file_path = upload_dir / filename + with open(file_path, "wb") as f: + f.write(contents) + user_tenant.avatar_url = f"/uploads/avatars/{filename}" - # Guardar archivo - with open(file_path, "wb") as f: - f.write(contents) + db.add(user_tenant) + db.commit() + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + db.rollback() + raise HTTPException( + status_code=500, detail=f"Error al guardar el avatar: {str(e)}" + ) from e - # Retornar URL relativa - avatar_url = f"/uploads/avatars/{filename}" - - return {"avatar_url": avatar_url} + public_url = public_user_avatar_api_path(tenant_id, keycloak_user_id) + return {"avatar_url": public_url} diff --git a/backend/api/v1/modules/core/users/service.py b/backend/api/v1/modules/core/users/service.py index b7b313ff..38464160 100644 --- a/backend/api/v1/modules/core/users/service.py +++ b/backend/api/v1/modules/core/users/service.py @@ -43,9 +43,16 @@ def _normalize_keycloak_user( # Agregar campos de perfil si user_tenant está disponible if user_tenant: + avatar_out = user_tenant.avatar_url + if avatar_out and str(avatar_out).startswith("tenants/"): + from core.s3_keys import public_user_avatar_api_path + + avatar_out = public_user_avatar_api_path( + user_tenant.tenant_id, user_tenant.keycloak_user_id + ) normalized.update( { - "avatar_url": user_tenant.avatar_url, + "avatar_url": avatar_out, "phone": user_tenant.phone, "bio": user_tenant.bio, "preferences": user_tenant.preferences or {}, @@ -435,7 +442,9 @@ class UserService: # Actualizar campos en UserTenant if role is not None: user_tenant.role = role - if avatar_url is not None: + if avatar_url is not None and not str(avatar_url).startswith( + "/api/v1/core/users/avatar/" + ): user_tenant.avatar_url = avatar_url if phone is not None: user_tenant.phone = phone @@ -641,7 +650,9 @@ class UserService: self.keycloak_admin.update_user(keycloak_user_id, update_data) # Actualizar campos de perfil en UserTenant - if avatar_url is not None: + if avatar_url is not None and not str(avatar_url).startswith( + "/api/v1/core/users/avatar/" + ): user_tenant.avatar_url = avatar_url if phone is not None: user_tenant.phone = phone diff --git a/backend/core/config.py b/backend/core/config.py index eaabdb82..e356a397 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -3,7 +3,7 @@ Configuración centralizada de la aplicación usando Pydantic Settings """ import os -from typing import List +from typing import List, Literal from pydantic import field_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -66,6 +66,18 @@ class Settings(BaseSettings): SMTP_FROM_NAME: str = "Sistema Anexo76" SMTP_USE_TLS: bool = True + # CSV imports (layouts_csv): redis = base64 en Valkey; minio = S3 + referencia en Redis + CSV_IMPORT_STORAGE: Literal["redis", "minio"] = "minio" + S3_ENDPOINT_URL: str = "http://minio:9000" + S3_ACCESS_KEY: str = "" + S3_SECRET_KEY: str = "" + S3_BUCKET: str = "anexo76" + S3_REGION: str = "us-east-1" + S3_USE_SSL: bool = False + # Logos, certificados, help (si no quieres MinIO aquí, pon false Y CSV_IMPORT_STORAGE=redis) + S3_FILE_STORAGE: bool = True + S3_PRESIGNED_EXPIRES_SECONDS: int = 3600 + model_config = SettingsConfigDict( env_file=[".env", "../.env"], case_sensitive=True, @@ -95,6 +107,14 @@ class Settings(BaseSettings): """Lista de orígenes CORS permitidos""" return [origin.strip() for origin in self.CORS_ORIGINS.split(",")] + @property + def use_s3_object_storage(self) -> bool: + """ + Usar MinIO para logos, certificados y Help (mismo bucket que CSV). + True si los imports CSV ya usan MinIO o si S3_FILE_STORAGE está activo. + """ + return self.CSV_IMPORT_STORAGE == "minio" or self.S3_FILE_STORAGE + # Instancia global de configuración settings = Settings() diff --git a/backend/core/middleware.py b/backend/core/middleware.py index edd39f75..9893b57f 100644 --- a/backend/core/middleware.py +++ b/backend/core/middleware.py @@ -22,6 +22,7 @@ class TenantMiddleware(BaseHTTPMiddleware): "/api/", "/uploads", "/api/v1/core/help-center", + "/api/v1/core/users/avatar", ] path = request.url.path @@ -88,6 +89,7 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware): "/api/health", "/api/", "/api/v1/core/help-center", + "/api/v1/core/users/avatar", ] # Verificar si la ruta está exenta (comparación exacta o prefijo) diff --git a/backend/core/s3_keys.py b/backend/core/s3_keys.py new file mode 100644 index 00000000..1108b02f --- /dev/null +++ b/backend/core/s3_keys.py @@ -0,0 +1,325 @@ +""" +Convención de claves S3/MinIO para objetos persistidos. + +Todas las cargas que usen ``put_object_bytes`` deben obtener la clave mediante +funciones de este módulo (no construir ``tenants/...`` a mano en las rutas HTTP). + +Árbol canónico +-------------- + +**Multi-tenant** (datos de clientes), siempre bajo ``tenants/{tenant_id}/``: + +- ``tenants/{tid}/users/{keycloak_sub}/`` + Perfil de usuario (avatar). Ver ``tenant_user_prefix``, ``user_avatar_key``. + +- ``tenants/{tid}/companies/{company_id}/`` + Recursos ligados a una empresa: + + - ``.../branding/{filename}`` — logo. ``company_logo_key``. + - ``.../certificates/{tipo}_{timestamp}.{cer|key}`` — CER/KEY FIEL, CFDI, cancelación. + ``company_certificate_key``. + - ``.../imports/csv/{job_type}/{job_id}.csv`` — CSV de layouts (import jobs). + ``csv_import_key`` (usado por ``storage_s3.s3_key_for_csv_import``). + - ``.../customs_brokers/{broker_id}/certificates/`` — CER del VU (``.cer``). + ``customs_broker_vu_certificate_key``. + - ``.../customs_brokers/{broker_id}/keys/`` — llave privada VU (``.key``). + ``customs_broker_vu_private_key_key``. + - ``.../customs_brokers/{broker_id}/cove/`` — archivos COVE (xml, zip, etc.). + ``customs_broker_vu_cove_key``. + +**Sistema global** (no por tenant): + +- ``system/help/{carpeta opcional}/{archivo}`` — biblioteca de ayuda (imágenes, PDFs, vídeos). + ``help_asset_key``, ``global_system_prefix``. Lectura HTTP mapea a este prefijo. + +**Legado / migración**: + +- ``imports/csv/{job_type}/{job_id}.csv`` — sin tenant/company. Solo ``legacy_csv_import_key`` + (cleanup o compatibilidad). + +Constantes públicas +------------------- + +``SYSTEM_HELP_PREFIX`` — prefijo literal ``system/help/`` para lecturas y utilidades +que no pasan por ``help_asset_key``. +""" +import re +from typing import Union + +# Segmentos permitidos en claves (evita path traversal) +_SAFE_SEGMENT = re.compile(r"^[a-zA-Z0-9._\-]+$") + +# Prefijo fijo para objetos de Help Center (debe coincidir con help_asset_key / GET /files/) +SYSTEM_HELP_PREFIX = "system/help/" + + +def _segment(value: Union[int, str], label: str) -> str: + s = str(value).strip() + if not s or "/" in s or ".." in s: + raise ValueError(f"invalid {label} segment") + if not _SAFE_SEGMENT.match(s): + raise ValueError(f"invalid {label} characters") + return s + + +def tenant_company_prefix(tenant_id: Union[int, str], company_id: int) -> str: + """Prefijo `tenants/{tid}/companies/{cid}/` (termina en /).""" + tid = _segment(tenant_id, "tenant_id") + cid = _segment(company_id, "company_id") + return f"tenants/{tid}/companies/{cid}/" + + +def tenant_user_prefix(tenant_id: Union[int, str], keycloak_user_id: str) -> str: + """Prefijo `tenants/{tid}/users/{keycloak_sub}/` (avatar de perfil, sin company).""" + tid = _segment(tenant_id, "tenant_id") + kid = _segment(keycloak_user_id, "keycloak_user_id") + return f"tenants/{tid}/users/{kid}/" + + +def user_avatar_key( + tenant_id: Union[int, str], + keycloak_user_id: str, + ext: str, +) -> str: + ext = ext.lower() if ext.startswith(".") else f".{ext}" + allowed = (".jpg", ".jpeg", ".png", ".gif", ".webp") + if ext not in allowed: + raise ValueError("invalid avatar extension") + return f"{tenant_user_prefix(tenant_id, keycloak_user_id)}avatar{ext}" + + +def public_user_avatar_api_path(tenant_id: int, keycloak_user_id: str) -> str: + """Ruta GET pública para servir la imagen (sin host).""" + return f"/api/v1/core/users/avatar/{tenant_id}/{keycloak_user_id}" + + +def global_system_prefix(subpath: str = "help") -> str: + """Prefijo bajo `system/` para contenido global (p. ej. help). Termina en /.""" + sub = subpath.strip().strip("/") + if not sub: + return SYSTEM_HELP_PREFIX + parts = sub.split("/") + for p in parts: + _segment(p, "system_subpath") + return f"system/{sub}/" + + +def customs_broker_vu_prefix( + tenant_id: Union[int, str], + company_id: int, + broker_id: int, +) -> str: + """ + Prefijo para el bloque VU del agente aduanal. + + Forma: ``tenants/{tid}/companies/{cid}/customs_brokers/{broker_id}/`` + """ + bid = _segment(str(broker_id), "broker_id") + return f"{tenant_company_prefix(tenant_id, company_id)}customs_brokers/{bid}/" + + +def customs_broker_vu_certificate_key( + tenant_id: Union[int, str], + company_id: int, + broker_id: int, + timestamp: str, + file_ext: str, +) -> str: + """CER del VU bajo ``.../customs_brokers/{id}/certificates/vu_cer_{timestamp}.cer``.""" + ts = _segment(timestamp, "timestamp") + ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}" + if ext != ".cer": + raise ValueError("VU certificate must be .cer") + base = f"vu_cer_{ts}{ext}" + return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}certificates/{base}" + + +def customs_broker_vu_private_key_key( + tenant_id: Union[int, str], + company_id: int, + broker_id: int, + timestamp: str, + file_ext: str, +) -> str: + """Llave privada del VU bajo ``.../customs_brokers/{id}/keys/vu_key_{timestamp}.key``.""" + ts = _segment(timestamp, "timestamp") + ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}" + if ext != ".key": + raise ValueError("VU private key must be .key") + base = f"vu_key_{ts}{ext}" + return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}keys/{base}" + + +def customs_broker_vu_cove_key( + tenant_id: Union[int, str], + company_id: int, + broker_id: int, + timestamp: str, + original_filename: str, +) -> str: + """ + Archivos COVE bajo ``.../customs_brokers/{id}/cove/cove_{timestamp}_{filename}``. + Extensiones típicas: .xml, .zip, .txt, .pdf, .json + """ + ts = _segment(timestamp, "timestamp") + fn = safe_filename(original_filename) + parts = fn.rsplit(".", 1) + if len(parts) < 2: + raise ValueError("COVE file must have an extension") + ext = "." + parts[1].lower() + allowed = (".xml", ".zip", ".txt", ".pdf", ".json") + if ext not in allowed: + raise ValueError(f"COVE extension not allowed: {ext}") + base = f"cove_{ts}_{fn}" + return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}cove/{base}" + + +def customs_broker_vu_doda_certificate_key( + tenant_id: Union[int, str], + company_id: int, + broker_id: int, + timestamp: str, + file_ext: str, +) -> str: + """CER DODA bajo ``.../customs_brokers/{id}/doda/certificates/doda_cer_{timestamp}.cer``.""" + ts = _segment(timestamp, "timestamp") + ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}" + if ext != ".cer": + raise ValueError("DODA certificate must be .cer") + base = f"doda_cer_{ts}{ext}" + return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}doda/certificates/{base}" + + +def customs_broker_vu_doda_private_key_key( + tenant_id: Union[int, str], + company_id: int, + broker_id: int, + timestamp: str, + file_ext: str, +) -> str: + """Llave DODA bajo ``.../customs_brokers/{id}/doda/keys/doda_key_{timestamp}.key``.""" + ts = _segment(timestamp, "timestamp") + ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}" + if ext != ".key": + raise ValueError("DODA private key must be .key") + base = f"doda_key_{ts}{ext}" + return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}doda/keys/{base}" + + +def customs_broker_vu_doda_cove_key( + tenant_id: Union[int, str], + company_id: int, + broker_id: int, + timestamp: str, + original_filename: str, +) -> str: + """ + Archivos DODA XML bajo ``.../customs_brokers/{id}/doda/cove/doda_cove_{timestamp}_{filename}``. + Extensiones permitidas: .xml, .zip, .txt, .pdf, .json + """ + ts = _segment(timestamp, "timestamp") + fn = safe_filename(original_filename) + parts = fn.rsplit(".", 1) + if len(parts) < 2: + raise ValueError("DODA file must have an extension") + ext = "." + parts[1].lower() + allowed = (".xml", ".zip", ".txt", ".pdf", ".json") + if ext not in allowed: + raise ValueError(f"DODA extension not allowed: {ext}") + base = f"doda_cove_{ts}_{fn}" + return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}doda/cove/{base}" + + +def job_type_segment(job_type: str) -> str: + if job_type == "" or job_type == "invoice": + return "invoice" + return job_type + + +def csv_import_key( + tenant_id: Union[int, str], + company_id: int, + job_type: str, + job_id: str, +) -> str: + _segment(job_id, "job_id") + return ( + f"{tenant_company_prefix(tenant_id, company_id)}" + f"imports/csv/{job_type_segment(job_type)}/{job_id}.csv" + ) + + +def legacy_csv_import_key(job_type: str, job_id: str) -> str: + """Clave antigua sin tenant/company (solo migración / cleanup).""" + _segment(job_id, "job_id") + return f"imports/csv/{job_type_segment(job_type)}/{job_id}.csv" + + +def safe_filename(filename: str) -> str: + """Nombre de archivo final sin separadores.""" + base = filename.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] + if not base or ".." in base: + raise ValueError("invalid filename") + return base + + +def company_logo_key( + tenant_id: Union[int, str], + company_id: int, + filename: str, +) -> str: + fn = safe_filename(filename) + return f"{tenant_company_prefix(tenant_id, company_id)}branding/{fn}" + + +def company_certificate_key( + tenant_id: Union[int, str], + company_id: int, + certificate_type: str, + timestamp: str, + file_ext: str, +) -> str: + ct = _segment(certificate_type.replace(".", "_"), "certificate_type") + ts = _segment(timestamp, "timestamp") + ext = file_ext.lower() if file_ext.startswith(".") else f".{file_ext}" + if ext not in (".cer", ".key"): + raise ValueError("certificate file must be .cer or .key") + base = f"{ct}_{ts}{ext}" + return f"{tenant_company_prefix(tenant_id, company_id)}certificates/{base}" + + +def help_asset_key(folder: str, new_filename: str) -> str: + """ + folder: '', 'pdfs', 'videos', 'assets' relativo a system/help/ + """ + folder = folder.strip().strip("/") + fn = safe_filename(new_filename) + if folder: + for p in folder.split("/"): + _segment(p, "help_folder") + return f"{global_system_prefix('help')}{folder}/{fn}" + return f"{global_system_prefix('help')}{fn}" + + +def help_s3_key_to_public_relative_path(key: str) -> str: + """Parte tras `system/help/` para el path del endpoint público.""" + if not key.startswith(SYSTEM_HELP_PREFIX): + raise ValueError("key is not under system/help/") + return key[len(SYSTEM_HELP_PREFIX) :] + + +def help_public_api_path(relative_under_help: str) -> str: + """URL de lectura pública bajo el router help-center (sin host).""" + rel = relative_under_help.lstrip("/") + return f"/api/v1/core/help-center/files/{rel}" + + +def system_help_object_key(relative_path: str) -> str: + """ + Clave S3 completa bajo ``system/help/`` para un path relativo (p. ej. GET /files/...). + ``relative_path`` no debe empezar por / ni contener '..'. + """ + rel = relative_path.strip().lstrip("/") + if ".." in rel or not rel: + raise ValueError("invalid help object path") + return f"{SYSTEM_HELP_PREFIX}{rel}" diff --git a/backend/core/storage_s3.py b/backend/core/storage_s3.py new file mode 100644 index 00000000..555d6ab3 --- /dev/null +++ b/backend/core/storage_s3.py @@ -0,0 +1,120 @@ +""" +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 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 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) diff --git a/backend/main.py b/backend/main.py index c2d060ac..54685087 100644 --- a/backend/main.py +++ b/backend/main.py @@ -17,6 +17,7 @@ from api.v1.modules.a76.audit_log.middleware import UserContextMiddleware # Midd from api.v1.modules.a76.audit_log.register import register_audit from api.v1.router import router as api_v1_router from core.config import settings +from core.storage_s3 import ensure_s3_bucket from core.paths import layout_path from core.error_handlers import register_exception_handlers from core.middleware import ( @@ -54,7 +55,9 @@ async def on_startup(): """Evento de inicio de la aplicación""" logger.info("Iniciando la aplicación Anexo76...") #init_db() - run_migrations() + run_migrations() + if settings.use_s3_object_storage: + ensure_s3_bucket() logger.info("Base de datos inicializada correctamente.") diff --git a/backend/requirements.txt b/backend/requirements.txt index 7480be9f..86175896 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -20,6 +20,7 @@ passlib[bcrypt]==1.7.4 # HTTP & API httpx==0.28.1 requests==2.32.5 +boto3==1.35.36 # Utilities diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e823b73e..75af28a0 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -178,6 +178,15 @@ services: - CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""} - SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production} - SPOKE_URLS=${SPOKE_URLS:-""} + - CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio} + - S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000} + - S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}} + - S3_SECRET_KEY=${S3_SECRET_KEY:-${MINIO_ROOT_PASSWORD:-minioadmin}} + - S3_BUCKET=${S3_BUCKET:-anexo76} + - S3_REGION=${S3_REGION:-us-east-1} + - S3_USE_SSL=${S3_USE_SSL:-false} + - S3_FILE_STORAGE=${S3_FILE_STORAGE:-true} + - S3_PRESIGNED_EXPIRES_SECONDS=${S3_PRESIGNED_EXPIRES_SECONDS:-3600} ports: - "3467:8000" depends_on: @@ -185,6 +194,8 @@ services: condition: service_healthy keycloak: condition: service_healthy + minio: + condition: service_healthy volumes: - backend_uploads:/app/uploads - backend_layouts:/app/layouts @@ -231,6 +242,14 @@ services: - SITAR_API_URL=${SITAR_API_URL} - SITAR_API_USER=${SITAR_API_USER} - SITAR_API_PASSWORD=${SITAR_API_PASSWORD} + - CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio} + - S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000} + - S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}} + - S3_SECRET_KEY=${S3_SECRET_KEY:-${MINIO_ROOT_PASSWORD:-minioadmin}} + - S3_BUCKET=${S3_BUCKET:-anexo76} + - S3_REGION=${S3_REGION:-us-east-1} + - S3_USE_SSL=${S3_USE_SSL:-false} + - S3_FILE_STORAGE=${S3_FILE_STORAGE:-true} depends_on: - backend - valkey @@ -256,6 +275,14 @@ services: - SITAR_API_URL=${SITAR_API_URL} - SITAR_API_USER=${SITAR_API_USER} - SITAR_API_PASSWORD=${SITAR_API_PASSWORD} + - CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio} + - S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000} + - S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}} + - S3_SECRET_KEY=${S3_SECRET_KEY:-${MINIO_ROOT_PASSWORD:-minioadmin}} + - S3_BUCKET=${S3_BUCKET:-anexo76} + - S3_REGION=${S3_REGION:-us-east-1} + - S3_USE_SSL=${S3_USE_SSL:-false} + - S3_FILE_STORAGE=${S3_FILE_STORAGE:-true} depends_on: - backend - valkey @@ -274,6 +301,33 @@ services: networks: - backend-net + minio: + image: minio/minio:RELEASE.2025-09-07T16-13-09Z + container_name: anexo76-minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} + ports: + - "${MINIO_API_PORT:-9100}:9000" + - "${MINIO_CONSOLE_PORT:-9101}:9001" + volumes: + - minio_data:/data + networks: + - backend-net + restart: unless-stopped + healthcheck: + test: [ "CMD-SHELL", "curl -f http://127.0.0.1:9000/minio/health/live || exit 1" ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + # Frontend - SvelteKit frontend: image: dev.aduanasoft.com/anexo76/frontend:latest @@ -336,6 +390,8 @@ volumes: driver: local backend_layouts: driver: local + minio_data: + driver: local networks: backend-net: diff --git a/docker-compose.yml b/docker-compose.yml index 6bb012de..68d14cd0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -182,6 +182,15 @@ services: - CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""} - SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production} - SPOKE_URLS=${SPOKE_URLS:-""} + - CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio} + - S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000} + - S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}} + - S3_SECRET_KEY=${S3_SECRET_KEY:-${MINIO_ROOT_PASSWORD:-minioadmin}} + - S3_BUCKET=${S3_BUCKET:-anexo76} + - S3_REGION=${S3_REGION:-us-east-1} + - S3_USE_SSL=${S3_USE_SSL:-false} + - S3_FILE_STORAGE=${S3_FILE_STORAGE:-true} + - S3_PRESIGNED_EXPIRES_SECONDS=${S3_PRESIGNED_EXPIRES_SECONDS:-3600} ports: - "8000:8000" depends_on: @@ -189,6 +198,8 @@ services: condition: service_healthy keycloak: condition: service_healthy + minio: + condition: service_healthy volumes: - ./backend:/app - backend_cache:/app/__pycache__ @@ -293,6 +304,14 @@ services: - SITAR_API_URL=${SITAR_API_URL} - SITAR_API_USER=${SITAR_API_USER} - SITAR_API_PASSWORD=${SITAR_API_PASSWORD} + - CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio} + - S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000} + - S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}} + - S3_SECRET_KEY=${S3_SECRET_KEY:-${MINIO_ROOT_PASSWORD:-minioadmin}} + - S3_BUCKET=${S3_BUCKET:-anexo76} + - S3_REGION=${S3_REGION:-us-east-1} + - S3_USE_SSL=${S3_USE_SSL:-false} + - S3_FILE_STORAGE=${S3_FILE_STORAGE:-true} depends_on: - backend - valkey @@ -317,6 +336,14 @@ services: - CORE_DB_NAME=${CORE_DB_NAME:-anexo76_core} - CORE_DB_USER=${CORE_DB_USER:-postgres} - CORE_DB_PASSWORD=${POSTGRES_APP_PASSWORD:-postgres} + - CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio} + - S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000} + - S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}} + - S3_SECRET_KEY=${S3_SECRET_KEY:-${MINIO_ROOT_PASSWORD:-minioadmin}} + - S3_BUCKET=${S3_BUCKET:-anexo76} + - S3_REGION=${S3_REGION:-us-east-1} + - S3_USE_SSL=${S3_USE_SSL:-false} + - S3_FILE_STORAGE=${S3_FILE_STORAGE:-true} depends_on: - backend - valkey @@ -336,6 +363,33 @@ services: networks: - backend-net + minio: + image: minio/minio:RELEASE.2025-09-07T16-13-09Z + container_name: anexo76-minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} + ports: + - "${MINIO_API_PORT:-9100}:9000" + - "${MINIO_CONSOLE_PORT:-9101}:9001" + volumes: + - minio_data:/data + networks: + - backend-net + restart: unless-stopped + healthcheck: + test: [ "CMD-SHELL", "curl -f http://127.0.0.1:9000/minio/health/live || exit 1" ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + volumes: postgres_app_data: driver: local @@ -349,6 +403,8 @@ volumes: driver: local backend_uploads: driver: local + minio_data: + driver: local networks: backend-net: diff --git a/frontend/src/lib/api/dashboard/a76/customs-brokers.ts b/frontend/src/lib/api/dashboard/a76/customs-brokers.ts index 1362a11a..4dc83595 100644 --- a/frontend/src/lib/api/dashboard/a76/customs-brokers.ts +++ b/frontend/src/lib/api/dashboard/a76/customs-brokers.ts @@ -1,6 +1,24 @@ import { api } from '$lib/api'; import { companyStore } from '$lib/stores/company.svelte'; // <--- NUEVO: Importamos el store para el fallback import type { ApiResponse } from '$lib/api'; +import { getToken } from '$lib/auth'; + +export type VuUploadFileKind = + | 'certificate' + | 'key' + | 'cove' + | 'doda_certificate' + | 'doda_key' + | 'doda_cove'; + +export interface CustomsBrokerVuUploadResult { + message: string; + file_kind: string; + field: string; + path: string; + broker_key: string; + company_id: number; +} export interface CustomsBroker { id: number; @@ -165,4 +183,40 @@ export const customsBrokersApi = { data ); } -}; \ No newline at end of file +}; + +/** + * Sube CER, KEY o COVE del VU al bucket (MinIO: tenants/.../customs_brokers/...). + */ +export async function uploadCustomsBrokerVuFile( + brokerKey: string, + companyId: string, + fileKind: VuUploadFileKind, + file: File +): Promise> { + const formData = new FormData(); + formData.append('file', file); + const token = getToken(); + const API_BASE_URL = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, ''); + const q = new URLSearchParams({ + company_id: companyId, + file_kind: fileKind + }); + const response = await fetch( + `${API_BASE_URL}/v1/a76/customs-brokers/${encodeURIComponent(brokerKey)}/vu/upload?${q.toString()}`, + { + method: 'POST', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: formData, + credentials: 'include' + } + ); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + return { + error: (data as { detail?: string }).detail || (data as { message?: string }).message || 'Error al subir el archivo VU', + status: response.status + }; + } + return { data: data as CustomsBrokerVuUploadResult, status: response.status }; +} \ No newline at end of file diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts index 11b037a1..f5814474 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts @@ -1,5 +1,6 @@ import { api } from '$lib/api'; import type { ApiResponse } from '$lib/api'; +import { getToken } from '$lib/auth'; export interface CompanyAddress { id?: number; @@ -450,14 +451,12 @@ export async function uploadCompanyLogo(id: number, file: File): Promise = T extends { child?: any } ? Omit : T; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte b/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte index 0c001f08..43de3704 100644 --- a/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte +++ b/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte @@ -4,6 +4,7 @@ import { companyStore } from '$lib/stores/company.svelte'; import { customsBrokersApi, + uploadCustomsBrokerVuFile, type CreateCustomsBrokerData } from '$lib/api/dashboard/a76/customs-brokers'; @@ -41,6 +42,7 @@ ShieldCheck } from 'lucide-svelte'; import { toast } from 'svelte-sonner'; + import { getFileDisplayName } from '$lib/utils'; import { useShortcuts } from '$lib/hooks/use-shortcuts'; import { obtenerAtajosEdicionAgente } from '$lib/config/shortcuts/dashboard/customs_brokers/edit'; @@ -100,6 +102,15 @@ archive_path: '' }); + let pendingVuFiles = $state<{ + certificate?: File; + key?: File; + cove?: File; + dodaCertificate?: File; + dodaKey?: File; + dodaCove?: File; + }>({}); + let brokerKeyError = $state(false); let licenseError = $state(false); let brokerKeyTimeout: ReturnType; @@ -121,10 +132,88 @@ const file = input.files?.[0]; if (file) { vuData[targetKey] = file.name; + if (targetKey === 'certificate_path') pendingVuFiles.certificate = file; + if (targetKey === 'key_path') pendingVuFiles.key = file; + if (targetKey === 'xml_files_path') pendingVuFiles.cove = file; + if (targetKey === 'doda_certificate_path') pendingVuFiles.dodaCertificate = file; + if (targetKey === 'doda_key_path') pendingVuFiles.dodaKey = file; + if (targetKey === 'doda_xml_files_path') pendingVuFiles.dodaCove = file; toast.success(`Archivo ${file.name} seleccionado`); } } + async function uploadPendingVuFiles(companyId: string, brokerKey: string) { + if (pendingVuFiles.certificate) { + const res = await uploadCustomsBrokerVuFile( + brokerKey, + companyId, + 'certificate', + pendingVuFiles.certificate + ); + if ((res as any).error || !(res as any).data?.path) { + throw new Error((res as any).error || 'Error al subir certificado VU (.cer)'); + } + vuData.certificate_path = (res as any).data.path; + } + + if (pendingVuFiles.key) { + const res = await uploadCustomsBrokerVuFile(brokerKey, companyId, 'key', pendingVuFiles.key); + if ((res as any).error || !(res as any).data?.path) { + throw new Error((res as any).error || 'Error al subir llave VU (.key)'); + } + vuData.key_path = (res as any).data.path; + } + + if (pendingVuFiles.cove) { + const res = await uploadCustomsBrokerVuFile(brokerKey, companyId, 'cove', pendingVuFiles.cove); + if ((res as any).error || !(res as any).data?.path) { + throw new Error((res as any).error || 'Error al subir archivo COVE'); + } + vuData.xml_files_path = (res as any).data.path; + } + + if (pendingVuFiles.dodaCertificate) { + const res = await uploadCustomsBrokerVuFile( + brokerKey, + companyId, + 'doda_certificate', + pendingVuFiles.dodaCertificate + ); + if ((res as any).error || !(res as any).data?.path) { + throw new Error((res as any).error || 'Error al subir certificado DODA (.cer)'); + } + vuData.doda_certificate_path = (res as any).data.path; + } + + if (pendingVuFiles.dodaKey) { + const res = await uploadCustomsBrokerVuFile( + brokerKey, + companyId, + 'doda_key', + pendingVuFiles.dodaKey + ); + if ((res as any).error || !(res as any).data?.path) { + throw new Error((res as any).error || 'Error al subir llave DODA (.key)'); + } + vuData.doda_key_path = (res as any).data.path; + } + + if (pendingVuFiles.dodaCove) { + const res = await uploadCustomsBrokerVuFile( + brokerKey, + companyId, + 'doda_cove', + pendingVuFiles.dodaCove + ); + if ((res as any).error || !(res as any).data?.path) { + throw new Error((res as any).error || 'Error al subir archivo DODA'); + } + vuData.doda_xml_files_path = (res as any).data.path; + } + + pendingVuFiles = {}; + } + async function loadBrokerData(key: string, cId: string) { if (!key || key === 'undefined') return; loading = true; @@ -279,6 +368,9 @@ if ((res as any).error) throw new Error((res as any).error); + // Primero sube archivos CER/KEY/COVE al bucket y guarda la ruta real en vuData. + await uploadPendingVuFiles(cId, formData.broker_key); + // UPSERT VU try { const vuRes = await customsBrokersApi.updateVU(formData.broker_key, vuData, cId); @@ -694,15 +786,20 @@
@@ -726,15 +823,20 @@
@@ -836,8 +938,13 @@ >
@@ -950,15 +1057,20 @@
@@ -982,15 +1094,20 @@
@@ -1039,8 +1156,13 @@ >
diff --git a/frontend/src/routes/dashboard/general_catalogs/company_information/edit/[[id]]/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/company_information/edit/[[id]]/+page.svelte index 09f7ced6..1401d421 100644 --- a/frontend/src/routes/dashboard/general_catalogs/company_information/edit/[[id]]/+page.svelte +++ b/frontend/src/routes/dashboard/general_catalogs/company_information/edit/[[id]]/+page.svelte @@ -16,7 +16,7 @@ type Company } from '$lib/api/dashboard/a76/general_catalogs/company'; import { companyStore } from '$lib/stores/company.svelte'; - import { getBackendAssetUrl } from '$lib/utils'; + import { getBackendAssetUrl, getFileDisplayName } from '$lib/utils'; import { ArrowLeft, LoaderCircle, @@ -498,7 +498,7 @@ successMessage = null; try { - const response = await uploadCompanyCertificate(Number(id), file, type); + const response = await uploadCompanyCertificate(Number(id), type, file); if (response.error) { throw new Error(response.error); @@ -1614,9 +1614,7 @@ > - {formData.fiel_cer - ? formData.fiel_cer.split('/').pop() - : 'Seleccionar archivo .cer'} + {getFileDisplayName(formData.fiel_cer, 'CER', 'Seleccionar archivo .cer')}
@@ -1638,9 +1636,7 @@ > - {formData.fiel_key - ? formData.fiel_key.split('/').pop() - : 'Seleccionar archivo .key'} + {getFileDisplayName(formData.fiel_key, 'KEY', 'Seleccionar archivo .key')}
@@ -1688,9 +1684,11 @@ > - {formData.cfdi_cert_cer - ? formData.cfdi_cert_cer.split('/').pop() - : 'Seleccionar archivo .cer'} + {getFileDisplayName( + formData.cfdi_cert_cer, + 'CER', + 'Seleccionar archivo .cer' + )}
@@ -1712,9 +1710,11 @@ > - {formData.cfdi_cert_key - ? formData.cfdi_cert_key.split('/').pop() - : 'Seleccionar archivo .key'} + {getFileDisplayName( + formData.cfdi_cert_key, + 'KEY', + 'Seleccionar archivo .key' + )}
@@ -1774,9 +1774,7 @@ > - {formData.cancel_cer - ? formData.cancel_cer.split('/').pop() - : 'Seleccionar archivo .cer'} + {getFileDisplayName(formData.cancel_cer, 'CER', 'Seleccionar archivo .cer')}
@@ -1798,9 +1796,7 @@ > - {formData.cancel_key - ? formData.cancel_key.split('/').pop() - : 'Seleccionar archivo .key'} + {getFileDisplayName(formData.cancel_key, 'KEY', 'Seleccionar archivo .key')}
diff --git a/frontend/src/routes/dashboard/help-center/editor/[uuid]/+page.svelte b/frontend/src/routes/dashboard/help-center/editor/[uuid]/+page.svelte index 2455fea8..d8ea7064 100644 --- a/frontend/src/routes/dashboard/help-center/editor/[uuid]/+page.svelte +++ b/frontend/src/routes/dashboard/help-center/editor/[uuid]/+page.svelte @@ -26,6 +26,7 @@ import { toast } from 'svelte-sonner'; import { marked } from 'marked'; import DOMPurify from 'dompurify'; + import { getFileNameFromPath } from '$lib/utils'; // Helper to resolve an API path from the DB (e.g., /api/uploads/...) to a full URL pointing to the local backend. function resolveAssetUrl(url: string | undefined): string { @@ -439,7 +440,9 @@

Archivo Cargado

-

{file_url}

+

+ {getFileNameFromPath(file_url)} +

{mime_type} • {file_size ? (file_size / 1024 / 1024).toFixed(2) : '??'} MB