Files
plantillas-proyectos/backend/api/v1/modules/a76/customs_brokers/routes.py
Galindo97 125405458c feat(auth): add hub_admin global access support across modules
- Add is_hub_admin() and resolve_tenant_id_required() to core/security
- hub_admin resolves tenant from company or uses None as global sentinel
- Update routes and services to skip tenant filter when tenant_id is None
- UserService accepts is_hub_admin flag for cross-tenant user management
- get_my_companies returns all companies for hub_admin without tenant restriction
2026-05-15 09:22:35 -05:00

403 lines
14 KiB
Python

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.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, resolve_tenant_id_required, 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,
db=None,
company_id: int = None,
):
"""Delega a resolve_tenant_id_required. Hub admin resuelve tenant desde la empresa."""
return resolve_tenant_id_required(current_user, db=db, company_id=company_id)
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"])
customs_broker_crud = TenantCRUDRoutes(
service=services.CustomsBrokerService,
create_schema=dto.CustomsBrokerCreateDTO,
update_schema=dto.CustomsBrokerUpdateDTO,
response_schema=dto.CustomsBrokerResponseDTO,
prefix="/customs-brokers",
tags=[],
resource_name="Customs Broker",
id_name="broker_key",
id_type=str,
enable_list=True,
list_permissions=["customs_brokers.view"],
get_permissions=["customs_brokers.view"],
create_permissions=["customs_brokers.create"],
update_permissions=["customs_brokers.edit"],
delete_permissions=["customs_brokers.delete"],
)
router.include_router(customs_broker_crud.router)
@router.patch(
"/customs-brokers/{broker_key}",
response_model=dto.CustomsBrokerResponseDTO,
)
def update_customs_broker(
broker_key: str,
broker_data: dto.CustomsBrokerUpdateDTO,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Actualización parcial (PATCH).
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, ["customs_brokers.edit"])
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")
updated_broker = services.CustomsBrokerService.update(
db=db,
broker_key=broker_key,
tenant_id=tenant_id,
broker_data=broker_data,
company_id=company_id
)
if not updated_broker:
raise HTTPException(status_code=400, detail="Error updating Customs Broker")
return updated_broker
@router.put(
"/customs-broker-vu/{broker_key}",
response_model=dto.CustomsBrokerVUResponseDTO,
)
def update_customs_broker_vu(
broker_key: str,
vu_data: dto.CustomsBrokerVUCreateDTO,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user, ["customs_brokers.edit"])
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")
updated_vu = services.CustomsBrokerVUService.update_vu(db, broker_key, vu_data, tenant_id, company_id)
if not updated_vu:
raise HTTPException(status_code=404, detail="Customs Broker VU not found")
return updated_vu
@router.put(
"/customs-broker-personnel/{broker_key}/{line}",
response_model=dto.CustomsBrokerPersonnelDTO,
)
def update_customs_broker_personnel(
broker_key: str,
line: int,
personnel_data: dto.CustomsBrokerPersonnelDTO,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user, ["customs_brokers.edit"])
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")
updated_personnel = services.CustomsBrokerPersonnelService.update_personnel(
db, broker_key, line, personnel_data, tenant_id, company_id
)
if not updated_personnel:
raise HTTPException(
status_code=404, detail="Customs Broker Personnel not found"
)
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, ["customs_brokers.create"])
tenant_id = _resolve_tenant_id_int(current_user, db=db, company_id=company_id)
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,
}