2687 lines
118 KiB
Python
2687 lines
118 KiB
Python
from django.shortcuts import render
|
|
from django.http import FileResponse, Http404
|
|
from django.db import transaction
|
|
|
|
from rest_framework.pagination import PageNumberPagination
|
|
from rest_framework.views import APIView
|
|
from rest_framework import viewsets
|
|
from rest_framework.permissions import IsAuthenticated
|
|
from rest_framework.parsers import MultiPartParser
|
|
from rest_framework.response import Response
|
|
from rest_framework import status
|
|
from rest_framework.exceptions import ValidationError
|
|
|
|
from .serializers import DocumentSerializer, FuenteSerializer, DocumentTypeSerializer
|
|
from .models import Document, Fuente, DocumentType
|
|
from ..customs.models import Pedimento
|
|
from ..vucem.models import CredencialesImportador
|
|
from api.organization.models import UsoAlmacenamiento
|
|
from io import BytesIO
|
|
import zipfile
|
|
from django.utils.text import slugify
|
|
from django.http import HttpResponse
|
|
from rest_framework.decorators import action
|
|
from datetime import timedelta
|
|
from django.utils import timezone
|
|
from api.utils.storage_service import storage_service
|
|
|
|
from rest_framework.authentication import TokenAuthentication
|
|
|
|
from core.permissions import (
|
|
get_org_context,
|
|
require_permission,
|
|
user_has_permission,
|
|
IsInternalService,
|
|
)
|
|
|
|
import logging
|
|
logger = logging.getLogger(__name__)
|
|
|
|
import os
|
|
import tempfile
|
|
from django.core.files.storage import default_storage
|
|
from django.conf import settings
|
|
import requests
|
|
import re
|
|
import xml.etree.ElementTree as ET
|
|
|
|
from mixins.filtrado_organizacion import DocumentosFiltradosMixin
|
|
|
|
# Configuración de patrones y mapeos (definirlo fuera del try para reutilización)
|
|
DOCUMENT_PATTERNS = {
|
|
'REQUEST': {
|
|
'VU_PC': (r".*VU_PC.*REQUEST\.xml$", 13, "Request Pedimento Completo VU"),
|
|
'VU_ED': (r".*VU_ED.*REQUEST\.xml$", 21, "Request E-Document VU"),
|
|
'VU_PT': (r".*VU_PT.*REQUEST\.xml$", 17, "Request Partidas VU"),
|
|
'VU_AC_COVE': (r".*VU_AC_COVE.*REQUEST\.xml$", 23, "Request Acuses COVES VU"),
|
|
'VU_COVE': (r".*VU_COVE.*REQUEST\.xml$", 19, "Request COVES VU"),
|
|
'VU_RM': (r".*VU_RM.*REQUEST\.xml$", 15, "Request Remesas VU"),
|
|
'VU_AC': (r".*VU_AC.*REQUEST\.xml$", 25, "Request Acuses VU"),
|
|
},
|
|
'ERROR': {
|
|
'VU_PC': (r".*VU_PC.*ERROR\.xml$", 14, "Error Pedimento Completo VU"),
|
|
'VU_ED': (r".*VU_ED.*ERROR\.xml$", 22, "Error E-Document VU"),
|
|
'VU_PT': (r".*VU_PT.*ERROR\.xml$", 18, "Error Partidas VU"),
|
|
'VU_AC_COVE': (r".*VU_AC_COVE.*ERROR\.xml$", 24, "Error Acuses COVES VU"),
|
|
'VU_COVE': (r".*VU_COVE.*ERROR\.xml$", 20, "Error COVES VU"),
|
|
'VU_RM': (r".*VU_RM.*ERROR\.xml$", 16, "Error Remesas VU"),
|
|
'VU_AC': (r".*VU_AC.*ERROR\.xml$", 26, "Error Acuses VU"),
|
|
}
|
|
}
|
|
|
|
def eliminar_documentos_existentes(organizacion, nombre_sin_extension, pedimento_id):
|
|
|
|
"""Elimina documentos existentes con el mismo nombre base"""
|
|
documentos_existentes = Document.objects.filter(
|
|
archivo__icontains=nombre_sin_extension,
|
|
organizacion=organizacion,
|
|
pedimento_id=pedimento_id
|
|
)
|
|
|
|
if not documentos_existentes.exists():
|
|
return
|
|
|
|
for doc_existente in documentos_existentes:
|
|
# Eliminar archivo físico si existe
|
|
if doc_existente.archivo and os.path.exists(doc_existente.archivo.path):
|
|
try:
|
|
os.remove(doc_existente.archivo.path)
|
|
except Exception as e:
|
|
logger.error(f"Error al eliminar archivo físico: {e}")
|
|
|
|
# Eliminar registros de la base de datos
|
|
documentos_existentes.delete()
|
|
|
|
def obtener_tipo_documento_por_patron(nombre_archivo, organizacion, pedimento_id):
|
|
"""Determina el tipo de documento basado en patrones de nombre"""
|
|
nombre_sin_extension = nombre_archivo.rsplit('.', 1)[0]
|
|
|
|
# Verificar patrones REQUEST
|
|
for doc_key, (patron, type_id, descripcion) in DOCUMENT_PATTERNS['REQUEST'].items():
|
|
if re.search(patron, nombre_archivo, re.IGNORECASE):
|
|
try:
|
|
# Eliminar documentos existentes
|
|
eliminar_documentos_existentes(organizacion, nombre_sin_extension, pedimento_id)
|
|
|
|
# Obtener tipo de documento
|
|
return DocumentType.objects.get(id=type_id)
|
|
except DocumentType.DoesNotExist:
|
|
raise ValidationError({
|
|
"error": f"El tipo de documento '{descripcion}' no existe. Por favor, créelo primero."
|
|
})
|
|
|
|
# Verificar patrones ERROR (si es necesario procesarlos)
|
|
for doc_key, (patron, type_id, descripcion) in DOCUMENT_PATTERNS['ERROR'].items():
|
|
if re.search(patron, nombre_archivo, re.IGNORECASE):
|
|
try:
|
|
# Eliminar documentos existentes
|
|
eliminar_documentos_existentes(organizacion, nombre_sin_extension, pedimento_id)
|
|
|
|
# Obtener tipo de documento
|
|
return DocumentType.objects.get(id=type_id)
|
|
except DocumentType.DoesNotExist:
|
|
raise ValidationError({
|
|
"error": f"El tipo de documento '{descripcion}' no existe. Por favor, créelo primero."
|
|
})
|
|
|
|
return None
|
|
|
|
# Apartado "Pedimento" del detalle: los XML se clasifican por contenido (no por nombre de
|
|
# archivo) usando los namespaces de las respuestas SOAP de VUCEM que deposita el microservicio,
|
|
# y se renombran a la nomenclatura canónica vu_PC_/vu_RM_{pedimento_app}.xml (tipos 2 y 3,
|
|
# los mismos que asigna el microservicio y que filtra PedimentoDocumentViewSet).
|
|
NS_PEDIMENTO_COMPLETO = 'http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpedimentocompleto'
|
|
NS_REMESAS = 'http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarremesas'
|
|
PEDIMENTO_TAB_TIPOS = {
|
|
NS_PEDIMENTO_COMPLETO: (2, 'vu_PC'),
|
|
NS_REMESAS: (3, 'vu_RM'),
|
|
}
|
|
|
|
|
|
def clasificar_xml_apartado_pedimento(file, pedimento):
|
|
"""Clasifica un XML subido al apartado Pedimento como Pedimento Completo o Remesa.
|
|
|
|
Devuelve (document_type_id, nombre_canonico). Lanza ValueError con un mensaje
|
|
apto para el usuario si el archivo no es XML, no clasifica o pertenece a otro pedimento.
|
|
"""
|
|
extension = file.name.split('.')[-1].lower() if '.' in file.name else ''
|
|
if extension != 'xml':
|
|
raise ValueError(f"'{file.name}': en este apartado solo se aceptan archivos XML")
|
|
|
|
try:
|
|
contenido = file.read()
|
|
file.seek(0)
|
|
root = ET.fromstring(contenido)
|
|
except ET.ParseError:
|
|
raise ValueError(f"'{file.name}': el archivo no es un XML válido")
|
|
|
|
tipo_encontrado = None
|
|
for elemento in root.iter():
|
|
for ns, mapeo in PEDIMENTO_TAB_TIPOS.items():
|
|
if isinstance(elemento.tag, str) and elemento.tag.startswith('{' + ns + '}'):
|
|
tipo_encontrado = (ns,) + mapeo
|
|
break
|
|
if tipo_encontrado:
|
|
break
|
|
|
|
if not tipo_encontrado:
|
|
raise ValueError(
|
|
f"'{file.name}': el XML no corresponde a un Pedimento Completo ni a una Remesa de VUCEM"
|
|
)
|
|
|
|
ns, type_id, prefijo = tipo_encontrado
|
|
|
|
# Validar pertenencia: el número de pedimento del XML debe coincidir con el actual.
|
|
# La respuesta de remesas no incluye el número, así que solo aplica a pedimento completo.
|
|
if ns == NS_PEDIMENTO_COMPLETO:
|
|
nodo = root.find(f'.//{{{ns}}}pedimento/{{{ns}}}pedimento')
|
|
numero_xml = re.sub(r'\D', '', nodo.text or '') if nodo is not None else ''
|
|
numero_actual = re.sub(r'\D', '', pedimento.pedimento or '')
|
|
if numero_xml and numero_actual and numero_xml != numero_actual:
|
|
raise ValueError(
|
|
f"'{file.name}': el XML corresponde al pedimento {nodo.text.strip()}, "
|
|
f"no al pedimento actual ({pedimento.pedimento_app})"
|
|
)
|
|
|
|
return type_id, f"{prefijo}_{pedimento.pedimento_app}.xml"
|
|
|
|
|
|
class CustomPagination(PageNumberPagination):
|
|
|
|
"""
|
|
Paginación personalizada con parámetros flexibles
|
|
- Si no se especifica page_size, devuelve todos los resultados (sin paginación)
|
|
- Si se especifica page_size, usa paginación normal
|
|
"""
|
|
page_size = None # Por defecto 10000 por página
|
|
page_size_query_param = 'page_size'
|
|
max_page_size = 10000 # Límite máximo de seguridad
|
|
page_query_param = 'page'
|
|
|
|
# Usar la paginación estándar de DRF, pero con page_size=10000 por defecto y máximo 10000
|
|
|
|
# Create your views here.
|
|
class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
|
|
"""
|
|
ViewSet for Document model.
|
|
"""
|
|
model = Document
|
|
|
|
pagination_class = CustomPagination
|
|
serializer_class = DocumentSerializer
|
|
filterset_fields = ['extension', 'size', 'document_type', 'pedimento', 'pedimento__pedimento', 'created_at']
|
|
my_tags = ['Documents']
|
|
|
|
def get_permissions(self):
|
|
# Service account (Token + superuser): acceso directo sin RBAC de org
|
|
if (self.request.user.is_authenticated and self.request.user.is_superuser and
|
|
isinstance(getattr(self.request, 'successful_authenticator', None), TokenAuthentication)):
|
|
return [IsAuthenticated(), IsInternalService()]
|
|
perms = {
|
|
'list': 'documentos.view',
|
|
'retrieve': 'documentos.view',
|
|
'create': 'documentos.upload',
|
|
'update': 'documentos.upload',
|
|
'partial_update': 'documentos.upload',
|
|
'destroy': 'documentos.delete',
|
|
'vu_documentos_errores': 'documentos.view',
|
|
'bulk_delete': 'documentos.delete',
|
|
'bulk_delete_partidas_vu': 'documentos.delete',
|
|
'bulk_delete_coves_vu': 'documentos.delete',
|
|
'bulk_delete_edocs_vu': 'documentos.delete',
|
|
'bulk_upload': 'documentos.upload',
|
|
'bulk_upload_vu': 'documentos.upload',
|
|
'create_vu_record': 'documentos.upload',
|
|
'bulk_download_partidas_vu': 'documentos.view',
|
|
'bulk_download_coves_vu': 'documentos.view',
|
|
'bulk_download_edocs_vu': 'documentos.view',
|
|
}
|
|
codename = perms.get(self.action, 'documentos.view')
|
|
return [IsAuthenticated(), require_permission(codename)()]
|
|
|
|
def get_queryset(self):
|
|
user = self.request.user
|
|
if user.is_superuser and isinstance(
|
|
getattr(self.request, 'successful_authenticator', None), TokenAuthentication
|
|
):
|
|
queryset = Document.objects.all()
|
|
else:
|
|
if not user_has_permission(user, 'documentos.view'):
|
|
return Document.objects.none()
|
|
queryset = self.get_queryset_filtrado_por_organizacion()
|
|
modulo_efc = self.request.query_params.get('modulo')
|
|
if modulo_efc:
|
|
if modulo_efc == 'expedientes-detalle-pedimentos':
|
|
queryset = queryset.exclude(document_type_id__in=['1','2','3','4','5','6','7','8','9','10','25','23','21','19','17','15','13','14','16','18','20','22','24','26'])
|
|
# Filtro personalizado por document_type
|
|
# document_type = self.request.query_params.get('document_type')
|
|
# if document_type:
|
|
# # Puedes agregar lógica personalizada aquí si es necesario
|
|
# if document_type == '1':
|
|
# queryset = queryset.filter(document_type_id=document_type)
|
|
# elif document_type == '2':
|
|
# queryset = queryset.filter(document_type_id=document_type)
|
|
# else:
|
|
# queryset = queryset.filter(document_type_id=document_type)
|
|
# else:
|
|
# queryset = queryset.filter(document_type_id='11')
|
|
|
|
fechaCreacion = self.request.query_params.get('created_at__date')
|
|
if fechaCreacion:
|
|
queryset = queryset.filter(created_at=fechaCreacion)
|
|
|
|
buscarArchivo = self.request.query_params.get('archivo__icontains')
|
|
if buscarArchivo:
|
|
queryset = queryset.filter(archivo__icontains=buscarArchivo)
|
|
|
|
|
|
pedimento_numero = self.request.query_params.get('pedimento_numero')
|
|
if pedimento_numero:
|
|
queryset = queryset.filter(pedimento__pedimento_app=pedimento_numero)
|
|
|
|
return queryset
|
|
|
|
@transaction.atomic
|
|
def perform_create(self, serializer):
|
|
user = self.request.user
|
|
if not user.is_authenticated or not hasattr(user, 'organizacion'):
|
|
raise ValidationError({"error": "Usuario no autenticado o sin organización"})
|
|
|
|
archivo = self.request.FILES.get('archivo')
|
|
if not archivo:
|
|
raise ValidationError({"archivo": "Se requiere un archivo para subir"})
|
|
|
|
# Permitir que el superusuario especifique la organización
|
|
organizacion = user.organizacion
|
|
|
|
if self.request.user.is_superuser:
|
|
organizacion = serializer.validated_data.get('organizacion', organizacion)
|
|
|
|
uso = UsoAlmacenamiento.objects.select_for_update().get_or_create(
|
|
organizacion=organizacion,
|
|
defaults={'espacio_utilizado': 0}
|
|
)[0]
|
|
|
|
# Calcular límites
|
|
max_almacenamiento_bytes = organizacion.licencia.almacenamiento * 1024 ** 3
|
|
nuevo_espacio_utilizado = uso.espacio_utilizado + archivo.size
|
|
|
|
# Validación estricta con raise ValidationError
|
|
if nuevo_espacio_utilizado > max_almacenamiento_bytes:
|
|
espacio_faltante = nuevo_espacio_utilizado - max_almacenamiento_bytes
|
|
raise ValidationError({
|
|
"error": "Espacio de almacenamiento insuficiente",
|
|
"detalle": {
|
|
"espacio_faltante_gb": round(espacio_faltante / (1024 ** 3), 2),
|
|
"espacio_utilizado_gb": round(uso.espacio_utilizado / (1024 ** 3), 2),
|
|
"limite_gb": organizacion.licencia.almacenamiento,
|
|
"archivo_gb": round(archivo.size / (1024 ** 3), 4)
|
|
},
|
|
"codigo": "storage_limit_exceeded"
|
|
}, code=status.HTTP_400_BAD_REQUEST)
|
|
|
|
|
|
try:
|
|
|
|
pedimento_id = serializer.validated_data.get('pedimento').id if serializer.validated_data.get('pedimento') else None
|
|
document_type = serializer.validated_data.get('document_type')
|
|
|
|
# Determinar el tipo de documento basado en el nombre del archivo
|
|
detected_type = obtener_tipo_documento_por_patron(archivo.name, organizacion, pedimento_id)
|
|
|
|
if detected_type:
|
|
document_type = detected_type
|
|
else:
|
|
# Lógica para archivos que no coinciden con los patrones conocidos
|
|
logger.warning(f"No se encontró patrón para archivo: {archivo.name}")
|
|
# Puedes mantener el document_type original o manejarlo de otra forma
|
|
|
|
except ValidationError as ve:
|
|
raise ve
|
|
except Exception as e:
|
|
# Como fallback, intentar obtener cualquier DocumentType existente
|
|
logger.error(f"Error al determinar el tipo de documento basado en el nombre del archivo: {e}")
|
|
|
|
try:
|
|
|
|
pedimento = serializer.validated_data.get('pedimento')
|
|
pedimento_app = pedimento.pedimento_app if pedimento else None
|
|
|
|
documento = Document.objects.create(
|
|
document_type=document_type,
|
|
organizacion=organizacion,
|
|
pedimento=pedimento,
|
|
size=archivo.size,
|
|
extension=archivo.name.split('.')[-1].lower()
|
|
)
|
|
|
|
ruta = storage_service.save_document(
|
|
file=archivo,
|
|
organizacion_id=organizacion.id,
|
|
pedimento_app=pedimento_app,
|
|
metadata={'source': 'document_create'}
|
|
)
|
|
|
|
if ruta:
|
|
documento.archivo = ruta
|
|
documento.save()
|
|
# si no agrego esto, el proceso no retorna todos los campos necesarios como id, si lo agrega a minIO pero no
|
|
# actualiza su status.
|
|
serializer.instance = documento
|
|
else:
|
|
documento.delete()
|
|
raise ValidationError({"archivo": "Error al guardar el archivo"})
|
|
|
|
except Exception as e:
|
|
# Guardar documento y actualizar espacio atómicamente
|
|
documento = serializer.save(
|
|
organizacion=organizacion,
|
|
size=archivo.size,
|
|
extension=archivo.name.split('.')[-1].lower()
|
|
)
|
|
|
|
uso.espacio_utilizado = nuevo_espacio_utilizado
|
|
uso.save()
|
|
|
|
@transaction.atomic
|
|
def perform_update(self, serializer):
|
|
instance = self.get_object()
|
|
new_file = self.request.FILES.get('archivo')
|
|
|
|
if new_file:
|
|
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
|
|
raise ValidationError({"error": "Usuario no autenticado o sin organización"})
|
|
|
|
organizacion = self.request.user.organizacion
|
|
uso = UsoAlmacenamiento.objects.select_for_update().get(organizacion=organizacion)
|
|
|
|
diferencia = new_file.size - instance.size
|
|
max_almacenamiento_bytes = organizacion.licencia.almacenamiento * 1024 ** 3
|
|
nuevo_espacio_utilizado = uso.espacio_utilizado + diferencia
|
|
|
|
if nuevo_espacio_utilizado > max_almacenamiento_bytes:
|
|
espacio_faltante = nuevo_espacio_utilizado - max_almacenamiento_bytes
|
|
raise ValidationError({
|
|
"error": "Espacio insuficiente para actualizar el archivo",
|
|
"detalle": {
|
|
"espacio_faltante_bytes": espacio_faltante,
|
|
"tamaño_nuevo_archivo": new_file.size,
|
|
"tamaño_anterior_archivo": instance.size
|
|
},
|
|
"codigo": "update_storage_limit_exceeded"
|
|
}, code=status.HTTP_400_BAD_REQUEST)
|
|
|
|
# Actualizar documento y espacio
|
|
if instance.archivo:
|
|
ruta_anterior = str(instance.archivo)
|
|
storage_service.delete_file(ruta_anterior)
|
|
|
|
pedimento = instance.pedimento
|
|
pedimento_app = pedimento.pedimento_app if pedimento else None
|
|
|
|
ruta = storage_service.save_document(
|
|
file=new_file,
|
|
organizacion_id=organizacion.id,
|
|
pedimento_app=pedimento_app,
|
|
metadata={'source': 'document_update'}
|
|
)
|
|
|
|
if ruta:
|
|
instance.archivo = ruta
|
|
instance.size = new_file.size
|
|
instance.extension = new_file.name.split('.')[-1].lower()
|
|
instance.save()
|
|
|
|
uso.espacio_utilizado = nuevo_espacio_utilizado
|
|
uso.save()
|
|
else:
|
|
raise ValidationError({"archivo": "Error al actualizar el archivo"})
|
|
else:
|
|
serializer.save()
|
|
|
|
def perform_destroy(self, instance):
|
|
from api.utils.storage_service import storage_service
|
|
|
|
if instance.archivo:
|
|
ruta = str(instance.archivo)
|
|
storage_service.delete_file(ruta)
|
|
|
|
# Restar el espacio al eliminar
|
|
uso = UsoAlmacenamiento.objects.get(organizacion=instance.organizacion)
|
|
uso.espacio_utilizado -= instance.size
|
|
uso.save()
|
|
|
|
instance.delete()
|
|
|
|
@action(detail=False, methods=['get'], url_path='vu-documentos-errores')
|
|
def vu_documentos_errores(self, request):
|
|
"""
|
|
Endpoint para obtener los documentos VU de error obtenidoss.
|
|
Filtra documentos cuyo document_type está en el rango de IDs de documentos VU (13-26).
|
|
"""
|
|
queryset = self.get_queryset().filter(vu=True)
|
|
|
|
pedimento_id = request.query_params.get('pedimentoId')
|
|
filtroExtension = request.query_params.get('extension')
|
|
filtroArchivo = request.query_params.get('archivo__icontains')
|
|
filtroFechaCreacion = request.query_params.get('created_at__date')
|
|
filtroTipoError = request.query_params.get('tipo_error')
|
|
filtroFuente = request.query_params.get('fuente')
|
|
document_type_ids = request.query_params.get('document_type_id')
|
|
|
|
if pedimento_id:
|
|
try:
|
|
pedimento_obj = Pedimento.objects.get(id=pedimento_id)
|
|
queryset = queryset.filter(pedimento_id=pedimento_id)
|
|
except Pedimento.DoesNotExist:
|
|
return Response(
|
|
{"error": "No se encontró el pedimento especificado"},
|
|
status=status.HTTP_404_NOT_FOUND
|
|
)
|
|
|
|
if filtroArchivo:
|
|
try:
|
|
queryset = queryset.filter(archivo__icontains=filtroArchivo)
|
|
except ValueError:
|
|
return Response(
|
|
{"error": "El parámetro Archivo debe ser caracteres válidos"},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
if filtroExtension:
|
|
try:
|
|
queryset = queryset.filter(extension__iexact=filtroExtension)
|
|
except ValueError:
|
|
return Response(
|
|
{"error": "El parámetro extension debe ser una extensión válida"},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
if filtroFechaCreacion:
|
|
from django.utils.dateparse import parse_date
|
|
|
|
fecha = parse_date(filtroFechaCreacion)
|
|
if not fecha:
|
|
return Response(
|
|
{"error": "El parámetro created_at__date debe tener el formato YYYY-MM-DD"},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
queryset = queryset.filter(created_at__date=fecha)
|
|
|
|
if filtroTipoError:
|
|
try:
|
|
ids = [int(i) for i in filtroTipoError.split(',')]
|
|
queryset = queryset.filter(document_type_id__in=ids)
|
|
except ValueError:
|
|
return Response(
|
|
{"error": "El parámetro document_type_id debe ser una lista de IDs separados por comas"},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
if filtroFuente:
|
|
try:
|
|
ids = [int(i) for i in filtroFuente.split(',')]
|
|
queryset = queryset.filter(fuente_id__in=ids)
|
|
except ValueError:
|
|
return Response(
|
|
{"error": "El parámetro fuente debe ser una lista de IDs separados por comas"},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
serializer = self.get_serializer(queryset, many=True)
|
|
return Response(serializer.data)
|
|
|
|
@action(detail=False, methods=['post'], url_path='bulk-delete')
|
|
def bulk_delete(self, request):
|
|
"""
|
|
Endpoint para eliminar múltiples documentos de manera masiva.
|
|
|
|
Payload esperado:
|
|
{
|
|
"ids": ["uuid1", "uuid2", "uuid3", ...]
|
|
}
|
|
|
|
Respuesta exitosa:
|
|
{
|
|
"message": "Documentos eliminados exitosamente",
|
|
"deleted_count": 3,
|
|
"deleted_ids": ["uuid1", "uuid2", "uuid3"],
|
|
"space_freed_mb": 25.6
|
|
}
|
|
|
|
Respuesta con errores:
|
|
{
|
|
"message": "Algunos documentos no pudieron ser eliminados",
|
|
"deleted_count": 2,
|
|
"deleted_ids": ["uuid1", "uuid2"],
|
|
"failed_ids": ["uuid3"],
|
|
"errors": ["No se encontró el documento con ID uuid3"],
|
|
"space_freed_mb": 15.2
|
|
}
|
|
"""
|
|
# Obtener los IDs del payload
|
|
ids = request.data.get('ids', [])
|
|
|
|
if not ids:
|
|
return Response(
|
|
{"error": "Se requiere una lista de IDs para eliminar"},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
if not isinstance(ids, list):
|
|
return Response(
|
|
{"error": "El campo 'ids' debe ser una lista"},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
# Obtener el queryset filtrado por organización
|
|
queryset = self.get_queryset()
|
|
|
|
# Filtrar solo los documentos que existen y pertenecen a la organización del usuario
|
|
existing_documents = queryset.filter(id__in=ids)
|
|
existing_ids = list(existing_documents.values_list('id', flat=True))
|
|
|
|
# Convertir UUIDs a strings para comparación
|
|
existing_ids_str = [str(id) for id in existing_ids]
|
|
requested_ids_str = [str(id) for id in ids]
|
|
|
|
# Identificar IDs que no existen o no pertenecen a la organización
|
|
failed_ids = [id for id in requested_ids_str if id not in existing_ids_str]
|
|
|
|
deleted_count = 0
|
|
total_space_freed = 0
|
|
errors = []
|
|
|
|
if existing_documents.exists():
|
|
try:
|
|
# Usar transacción atómica para consistencia
|
|
with transaction.atomic():
|
|
# Calcular el espacio total a liberar
|
|
total_space_freed = sum(doc.size for doc in existing_documents)
|
|
|
|
# Obtener la organización del usuario para actualizar el uso de almacenamiento
|
|
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
|
|
return Response(
|
|
{"error": "Usuario no autenticado o sin organización"},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
organizacion = request.user.organizacion
|
|
|
|
# Si es superusuario, puede eliminar documentos de cualquier organización
|
|
if request.user.is_superuser:
|
|
# Para superusuario, actualizar el uso de cada organización afectada
|
|
organizaciones_afectadas = {}
|
|
for doc in existing_documents:
|
|
if doc.organizacion.id not in organizaciones_afectadas:
|
|
organizaciones_afectadas[doc.organizacion.id] = {
|
|
'organizacion': doc.organizacion,
|
|
'espacio_liberado': 0
|
|
}
|
|
organizaciones_afectadas[doc.organizacion.id]['espacio_liberado'] += doc.size
|
|
|
|
# Actualizar uso de almacenamiento para cada organización
|
|
for org_data in organizaciones_afectadas.values():
|
|
try:
|
|
uso = UsoAlmacenamiento.objects.select_for_update().get(
|
|
organizacion=org_data['organizacion']
|
|
)
|
|
uso.espacio_utilizado -= org_data['espacio_liberado']
|
|
uso.save()
|
|
except UsoAlmacenamiento.DoesNotExist:
|
|
# Si no existe el registro, no hay nada que actualizar
|
|
pass
|
|
else:
|
|
# Para usuarios normales, solo documentos de su organización
|
|
try:
|
|
uso = UsoAlmacenamiento.objects.select_for_update().get(
|
|
organizacion=organizacion
|
|
)
|
|
uso.espacio_utilizado -= total_space_freed
|
|
uso.save()
|
|
except UsoAlmacenamiento.DoesNotExist:
|
|
# Si no existe el registro, no hay nada que actualizar
|
|
pass
|
|
|
|
# Eliminar los documentos (archivos físicos y registros de BD)
|
|
archivos_eliminados = 0
|
|
for doc in existing_documents:
|
|
try:
|
|
if doc.archivo:
|
|
ruta = str(doc.archivo)
|
|
storage_service.delete_file(ruta)
|
|
|
|
doc.delete()
|
|
archivos_eliminados += 1
|
|
except Exception as e:
|
|
errors.append(f"No se pudo eliminar el documento {doc.id}: {str(e)}")
|
|
failed_ids.append(str(doc.id))
|
|
|
|
deleted_count = archivos_eliminados
|
|
|
|
except Exception as e:
|
|
return Response(
|
|
{"error": f"Error al eliminar documentos: {str(e)}"},
|
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
|
)
|
|
|
|
# Agregar errores para IDs no encontrados
|
|
if failed_ids:
|
|
errors.extend([f"No se encontró el documento con ID {id} o no pertenece a su organización" for id in failed_ids])
|
|
|
|
# Convertir bytes a MB para la respuesta
|
|
space_freed_mb = round(total_space_freed / (1024 * 1024), 2)
|
|
|
|
# Preparar respuesta
|
|
response_data = {
|
|
"deleted_count": deleted_count,
|
|
"deleted_ids": existing_ids_str,
|
|
"space_freed_mb": space_freed_mb
|
|
}
|
|
|
|
if errors or failed_ids:
|
|
response_data.update({
|
|
"message": "Algunos documentos no pudieron ser eliminados",
|
|
"failed_ids": failed_ids,
|
|
"errors": errors
|
|
})
|
|
response_status = status.HTTP_207_MULTI_STATUS
|
|
else:
|
|
response_data["message"] = "Documentos eliminados exitosamente"
|
|
response_status = status.HTTP_200_OK
|
|
|
|
return Response(response_data, status=response_status)
|
|
|
|
@action(detail=False, methods=['post'], url_path='bulk-delete-partidas-vu')
|
|
def bulk_delete_partidas_vu(self, request):
|
|
from ..customs.models import Partida
|
|
|
|
ids_partidas = request.data.get('ids', [])
|
|
|
|
if not ids_partidas:
|
|
return Response(
|
|
{"error": "Se requiere una lista de IDs para eliminar"},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
if not isinstance(ids_partidas, list):
|
|
return Response(
|
|
{"error": "El campo 'ids' debe ser una lista"},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
partidas = Partida.objects.filter(id__in=ids_partidas).select_related('pedimento')
|
|
if not partidas.exists():
|
|
return Response(
|
|
{"error": "No se encontraron partidas con los IDs proporcionados"},
|
|
status=status.HTTP_404_NOT_FOUND
|
|
)
|
|
|
|
# Buscar documentos vu_PT_ asociados a cada partida por pedimento + numero_partida
|
|
doc_ids = []
|
|
for partida in partidas:
|
|
docs = Document.objects.filter(
|
|
pedimento_id=partida.pedimento.id,
|
|
archivo__icontains=f'vu_pt_{partida.pedimento.pedimento_app}_{partida.numero_partida}_'
|
|
).values_list('id', flat=True)
|
|
doc_ids.extend(docs)
|
|
|
|
queryset = self.get_queryset()
|
|
existing_documents = queryset.filter(id__in=doc_ids)
|
|
existing_ids = list(existing_documents.values_list('id', flat=True))
|
|
existing_ids_str = [str(i) for i in existing_ids]
|
|
|
|
deleted_count = 0
|
|
total_space_freed = 0
|
|
errors = []
|
|
failed_ids = []
|
|
|
|
try:
|
|
with transaction.atomic():
|
|
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
|
|
return Response(
|
|
{"error": "Usuario no autenticado o sin organización"},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
organizacion = request.user.organizacion
|
|
|
|
if existing_documents.exists():
|
|
total_space_freed = sum(doc.size for doc in existing_documents)
|
|
|
|
if request.user.is_superuser:
|
|
organizaciones_afectadas = {}
|
|
for doc in existing_documents:
|
|
if doc.organizacion.id not in organizaciones_afectadas:
|
|
organizaciones_afectadas[doc.organizacion.id] = {
|
|
'organizacion': doc.organizacion,
|
|
'espacio_liberado': 0
|
|
}
|
|
organizaciones_afectadas[doc.organizacion.id]['espacio_liberado'] += doc.size
|
|
for org_data in organizaciones_afectadas.values():
|
|
try:
|
|
uso = UsoAlmacenamiento.objects.select_for_update().get(
|
|
organizacion=org_data['organizacion']
|
|
)
|
|
uso.espacio_utilizado -= org_data['espacio_liberado']
|
|
uso.save()
|
|
except UsoAlmacenamiento.DoesNotExist:
|
|
pass
|
|
else:
|
|
try:
|
|
uso = UsoAlmacenamiento.objects.select_for_update().get(
|
|
organizacion=organizacion
|
|
)
|
|
uso.espacio_utilizado -= total_space_freed
|
|
uso.save()
|
|
except UsoAlmacenamiento.DoesNotExist:
|
|
pass
|
|
|
|
archivos_eliminados = 0
|
|
for doc in existing_documents:
|
|
try:
|
|
if doc.archivo:
|
|
storage_service.delete_file(str(doc.archivo))
|
|
doc.delete()
|
|
archivos_eliminados += 1
|
|
except Exception as e:
|
|
errors.append(f"No se pudo eliminar el documento {doc.id}: {str(e)}")
|
|
failed_ids.append(str(doc.id))
|
|
|
|
deleted_count = archivos_eliminados
|
|
|
|
# Eliminar los registros de Partida
|
|
partidas.delete()
|
|
|
|
except Exception as e:
|
|
return Response(
|
|
{"error": f"Error al eliminar: {str(e)}"},
|
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
|
)
|
|
|
|
if failed_ids:
|
|
errors.extend([
|
|
f"No se encontró el documento con ID {i} o no pertenece a su organización"
|
|
for i in failed_ids
|
|
])
|
|
|
|
space_freed_mb = round(total_space_freed / (1024 * 1024), 2)
|
|
|
|
response_data = {
|
|
"deleted_count": deleted_count,
|
|
"deleted_ids": existing_ids_str,
|
|
"space_freed_mb": space_freed_mb
|
|
}
|
|
|
|
if errors or failed_ids:
|
|
response_data.update({
|
|
"message": "Algunos documentos no pudieron ser eliminados",
|
|
"failed_ids": failed_ids,
|
|
"errors": errors
|
|
})
|
|
response_status = status.HTTP_207_MULTI_STATUS
|
|
else:
|
|
response_data["message"] = "Partidas y documentos eliminados exitosamente"
|
|
response_status = status.HTTP_200_OK
|
|
|
|
return Response(response_data, status=response_status)
|
|
|
|
|
|
@action(detail=False, methods=['post'], url_path='bulk-delete-coves-vu')
|
|
def bulk_delete_coves_vu(self, request):
|
|
from ..customs.models import Cove
|
|
|
|
ids_coves = request.data.get('ids', [])
|
|
|
|
if not ids_coves:
|
|
return Response(
|
|
{"error": "Se requiere una lista de IDs para eliminar"},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
if not isinstance(ids_coves, list):
|
|
return Response(
|
|
{"error": "El campo 'ids' debe ser una lista"},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
coves = Cove.objects.filter(id__in=ids_coves).select_related('pedimento')
|
|
if not coves.exists():
|
|
return Response(
|
|
{"error": "No se encontraron COVEs con los IDs proporcionados"},
|
|
status=status.HTTP_404_NOT_FOUND
|
|
)
|
|
|
|
# Buscar documentos que contengan el numero_cove en el nombre de archivo
|
|
doc_ids = []
|
|
for cove in coves:
|
|
docs = Document.objects.filter(
|
|
pedimento_id=cove.pedimento.id,
|
|
archivo__icontains=cove.numero_cove
|
|
).values_list('id', flat=True)
|
|
doc_ids.extend(docs)
|
|
|
|
queryset = self.get_queryset()
|
|
existing_documents = queryset.filter(id__in=doc_ids)
|
|
existing_ids = list(existing_documents.values_list('id', flat=True))
|
|
existing_ids_str = [str(i) for i in existing_ids]
|
|
|
|
deleted_count = 0
|
|
total_space_freed = 0
|
|
errors = []
|
|
failed_ids = []
|
|
|
|
try:
|
|
with transaction.atomic():
|
|
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
|
|
return Response(
|
|
{"error": "Usuario no autenticado o sin organización"},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
organizacion = request.user.organizacion
|
|
|
|
if existing_documents.exists():
|
|
total_space_freed = sum(doc.size for doc in existing_documents)
|
|
|
|
if request.user.is_superuser:
|
|
organizaciones_afectadas = {}
|
|
for doc in existing_documents:
|
|
if doc.organizacion.id not in organizaciones_afectadas:
|
|
organizaciones_afectadas[doc.organizacion.id] = {
|
|
'organizacion': doc.organizacion,
|
|
'espacio_liberado': 0
|
|
}
|
|
organizaciones_afectadas[doc.organizacion.id]['espacio_liberado'] += doc.size
|
|
for org_data in organizaciones_afectadas.values():
|
|
try:
|
|
uso = UsoAlmacenamiento.objects.select_for_update().get(
|
|
organizacion=org_data['organizacion']
|
|
)
|
|
uso.espacio_utilizado -= org_data['espacio_liberado']
|
|
uso.save()
|
|
except UsoAlmacenamiento.DoesNotExist:
|
|
pass
|
|
else:
|
|
try:
|
|
uso = UsoAlmacenamiento.objects.select_for_update().get(
|
|
organizacion=organizacion
|
|
)
|
|
uso.espacio_utilizado -= total_space_freed
|
|
uso.save()
|
|
except UsoAlmacenamiento.DoesNotExist:
|
|
pass
|
|
|
|
archivos_eliminados = 0
|
|
for doc in existing_documents:
|
|
try:
|
|
if doc.archivo:
|
|
storage_service.delete_file(str(doc.archivo))
|
|
doc.delete()
|
|
archivos_eliminados += 1
|
|
except Exception as e:
|
|
errors.append(f"No se pudo eliminar el documento {doc.id}: {str(e)}")
|
|
failed_ids.append(str(doc.id))
|
|
|
|
deleted_count = archivos_eliminados
|
|
|
|
coves.delete()
|
|
|
|
except Exception as e:
|
|
return Response(
|
|
{"error": f"Error al eliminar: {str(e)}"},
|
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
|
)
|
|
|
|
if failed_ids:
|
|
errors.extend([
|
|
f"No se encontró el documento con ID {i} o no pertenece a su organización"
|
|
for i in failed_ids
|
|
])
|
|
|
|
space_freed_mb = round(total_space_freed / (1024 * 1024), 2)
|
|
|
|
response_data = {
|
|
"deleted_count": deleted_count,
|
|
"deleted_ids": existing_ids_str,
|
|
"space_freed_mb": space_freed_mb
|
|
}
|
|
|
|
if errors or failed_ids:
|
|
response_data.update({
|
|
"message": "Algunos documentos no pudieron ser eliminados",
|
|
"failed_ids": failed_ids,
|
|
"errors": errors
|
|
})
|
|
response_status = status.HTTP_207_MULTI_STATUS
|
|
else:
|
|
response_data["message"] = "COVEs y documentos eliminados exitosamente"
|
|
response_status = status.HTTP_200_OK
|
|
|
|
return Response(response_data, status=response_status)
|
|
|
|
@action(detail=False, methods=['post'], url_path='bulk-delete-edocs-vu')
|
|
def bulk_delete_edocs_vu(self, request):
|
|
from ..customs.models import EDocument
|
|
|
|
ids_edocs = request.data.get('ids', [])
|
|
|
|
if not ids_edocs:
|
|
return Response(
|
|
{"error": "Se requiere una lista de IDs para eliminar"},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
if not isinstance(ids_edocs, list):
|
|
return Response(
|
|
{"error": "El campo 'ids' debe ser una lista"},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
edocs = EDocument.objects.filter(id__in=ids_edocs).select_related('pedimento')
|
|
if not edocs.exists():
|
|
return Response(
|
|
{"error": "No se encontraron EDocuments con los IDs proporcionados"},
|
|
status=status.HTTP_404_NOT_FOUND
|
|
)
|
|
|
|
# Buscar documentos que contengan el numero_edocument en el nombre de archivo
|
|
doc_ids = []
|
|
for edoc in edocs:
|
|
docs = Document.objects.filter(
|
|
pedimento_id=edoc.pedimento.id,
|
|
archivo__icontains=edoc.numero_edocument
|
|
).values_list('id', flat=True)
|
|
doc_ids.extend(docs)
|
|
|
|
queryset = self.get_queryset()
|
|
existing_documents = queryset.filter(id__in=doc_ids)
|
|
existing_ids = list(existing_documents.values_list('id', flat=True))
|
|
existing_ids_str = [str(i) for i in existing_ids]
|
|
|
|
deleted_count = 0
|
|
total_space_freed = 0
|
|
errors = []
|
|
failed_ids = []
|
|
|
|
try:
|
|
with transaction.atomic():
|
|
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
|
|
return Response(
|
|
{"error": "Usuario no autenticado o sin organización"},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
organizacion = request.user.organizacion
|
|
|
|
if existing_documents.exists():
|
|
total_space_freed = sum(doc.size for doc in existing_documents)
|
|
|
|
if request.user.is_superuser:
|
|
organizaciones_afectadas = {}
|
|
for doc in existing_documents:
|
|
if doc.organizacion.id not in organizaciones_afectadas:
|
|
organizaciones_afectadas[doc.organizacion.id] = {
|
|
'organizacion': doc.organizacion,
|
|
'espacio_liberado': 0
|
|
}
|
|
organizaciones_afectadas[doc.organizacion.id]['espacio_liberado'] += doc.size
|
|
for org_data in organizaciones_afectadas.values():
|
|
try:
|
|
uso = UsoAlmacenamiento.objects.select_for_update().get(
|
|
organizacion=org_data['organizacion']
|
|
)
|
|
uso.espacio_utilizado -= org_data['espacio_liberado']
|
|
uso.save()
|
|
except UsoAlmacenamiento.DoesNotExist:
|
|
pass
|
|
else:
|
|
try:
|
|
uso = UsoAlmacenamiento.objects.select_for_update().get(
|
|
organizacion=organizacion
|
|
)
|
|
uso.espacio_utilizado -= total_space_freed
|
|
uso.save()
|
|
except UsoAlmacenamiento.DoesNotExist:
|
|
pass
|
|
|
|
archivos_eliminados = 0
|
|
for doc in existing_documents:
|
|
try:
|
|
if doc.archivo:
|
|
storage_service.delete_file(str(doc.archivo))
|
|
doc.delete()
|
|
archivos_eliminados += 1
|
|
except Exception as e:
|
|
errors.append(f"No se pudo eliminar el documento {doc.id}: {str(e)}")
|
|
failed_ids.append(str(doc.id))
|
|
|
|
deleted_count = archivos_eliminados
|
|
|
|
edocs.delete()
|
|
|
|
except Exception as e:
|
|
return Response(
|
|
{"error": f"Error al eliminar: {str(e)}"},
|
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
|
)
|
|
|
|
if failed_ids:
|
|
errors.extend([
|
|
f"No se encontró el documento con ID {i} o no pertenece a su organización"
|
|
for i in failed_ids
|
|
])
|
|
|
|
space_freed_mb = round(total_space_freed / (1024 * 1024), 2)
|
|
|
|
response_data = {
|
|
"deleted_count": deleted_count,
|
|
"deleted_ids": existing_ids_str,
|
|
"space_freed_mb": space_freed_mb
|
|
}
|
|
|
|
if errors or failed_ids:
|
|
response_data.update({
|
|
"message": "Algunos documentos no pudieron ser eliminados",
|
|
"failed_ids": failed_ids,
|
|
"errors": errors
|
|
})
|
|
response_status = status.HTTP_207_MULTI_STATUS
|
|
else:
|
|
response_data["message"] = "EDocuments y documentos eliminados exitosamente"
|
|
response_status = status.HTTP_200_OK
|
|
|
|
return Response(response_data, status=response_status)
|
|
|
|
|
|
@action(detail=False, methods=['post'], url_path='bulk-upload', parser_classes=[MultiPartParser])
|
|
def bulk_upload(self, request):
|
|
"""
|
|
Endpoint para subir múltiples documentos a un pedimento específico.
|
|
|
|
FormData esperado:
|
|
- pedimento_id: UUID del pedimento (requerido)
|
|
- files: Lista de archivos a subir (requerido)
|
|
|
|
Nota: Se usa automáticamente el tipo de documento "Documento General"
|
|
|
|
Respuesta exitosa:
|
|
{
|
|
"message": "Documentos subidos exitosamente",
|
|
"uploaded_count": 5,
|
|
"uploaded_documents": [
|
|
{
|
|
"id": "uuid1",
|
|
"filename": "documento1.pdf",
|
|
"size": 1024000,
|
|
"extension": "pdf"
|
|
},
|
|
...
|
|
],
|
|
"space_used_mb": 25.6,
|
|
"failed_files": [],
|
|
"errors": []
|
|
}
|
|
|
|
Respuesta con errores:
|
|
{
|
|
"message": "Algunos documentos no pudieron ser subidos",
|
|
"uploaded_count": 3,
|
|
"uploaded_documents": [...],
|
|
"space_used_mb": 15.2,
|
|
"failed_files": ["archivo4.pdf", "archivo5.doc"],
|
|
"errors": ["Archivo demasiado grande: archivo4.pdf", "Tipo de archivo no soportado: archivo5.doc"]
|
|
}
|
|
"""
|
|
|
|
# Validar datos requeridos
|
|
pedimento_id = request.data.get('pedimento_id')
|
|
if not pedimento_id:
|
|
return Response(
|
|
{"error": "Se requiere el campo 'pedimento_id'"},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
files = request.FILES.getlist('files')
|
|
if not files:
|
|
return Response(
|
|
{"error": "Se requiere al menos un archivo para subir"},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
# Validar usuario autenticado
|
|
if not request.user.is_authenticated:
|
|
return Response(
|
|
{"error": "Usuario no autenticado"},
|
|
status=status.HTTP_401_UNAUTHORIZED
|
|
)
|
|
|
|
# Obtener el pedimento primero para usar su organización
|
|
from api.customs.models import Pedimento
|
|
try:
|
|
pedimento = Pedimento.objects.get(id=pedimento_id)
|
|
except Pedimento.DoesNotExist:
|
|
return Response(
|
|
{"error": "Pedimento no encontrado"},
|
|
status=status.HTTP_404_NOT_FOUND
|
|
)
|
|
|
|
# Usar la organización del pedimento
|
|
organizacion = pedimento.organizacion
|
|
|
|
# Validar que el usuario tenga permisos para esta organización
|
|
if not request.user.is_superuser:
|
|
if not hasattr(request.user, 'organizacion') or request.user.organizacion != organizacion:
|
|
return Response(
|
|
{"error": "No tienes permisos para subir documentos a este pedimento"},
|
|
status=status.HTTP_403_FORBIDDEN
|
|
)
|
|
|
|
# Usar tipo de documento indicado o "Documento General" por defecto
|
|
document_type_id_param = request.data.get('document_type_id')
|
|
if document_type_id_param:
|
|
try:
|
|
document_type = DocumentType.objects.get(id=int(document_type_id_param))
|
|
except (DocumentType.DoesNotExist, ValueError):
|
|
return Response(
|
|
{"error": f"Tipo de documento con ID '{document_type_id_param}' no encontrado"},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
else:
|
|
document_type, _ = DocumentType.objects.get_or_create(
|
|
nombre="Documento General",
|
|
defaults={'descripcion': "Documento general sin tipo específico"}
|
|
)
|
|
|
|
# Apartado del detalle desde el que se sube; 'pedimento' activa la
|
|
# clasificación del XML por contenido y el renombrado canónico
|
|
tab_seccion = request.data.get('tab_seccion')
|
|
|
|
uploaded_documents = []
|
|
failed_files = []
|
|
errors = []
|
|
total_space_used = 0
|
|
created_count = 0
|
|
replaced_count = 0
|
|
|
|
try:
|
|
with transaction.atomic():
|
|
# Obtener uso de almacenamiento
|
|
uso = UsoAlmacenamiento.objects.select_for_update().get_or_create(
|
|
organizacion=organizacion,
|
|
defaults={'espacio_utilizado': 0}
|
|
)[0]
|
|
|
|
# Calcular límites
|
|
max_almacenamiento_bytes = organizacion.licencia.almacenamiento * 1024 ** 3
|
|
espacio_inicial = uso.espacio_utilizado
|
|
|
|
# Calcular el tamaño total de todos los archivos
|
|
total_files_size = sum(file.size for file in files)
|
|
nuevo_espacio_total = espacio_inicial + total_files_size
|
|
|
|
# Validar que hay espacio suficiente para todos los archivos
|
|
if nuevo_espacio_total > max_almacenamiento_bytes:
|
|
espacio_faltante = nuevo_espacio_total - max_almacenamiento_bytes
|
|
return Response({
|
|
"error": "Espacio de almacenamiento insuficiente para todos los archivos",
|
|
"detalle": {
|
|
"espacio_faltante_gb": round(espacio_faltante / (1024 ** 3), 2),
|
|
"espacio_utilizado_gb": round(espacio_inicial / (1024 ** 3), 2),
|
|
"limite_gb": organizacion.licencia.almacenamiento,
|
|
"archivos_gb": round(total_files_size / (1024 ** 3), 4),
|
|
"total_archivos": len(files)
|
|
},
|
|
"codigo": "bulk_storage_limit_exceeded"
|
|
}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
# Cargar documentos existentes del pedimento para detectar y reemplazar duplicados
|
|
existing_docs = list(Document.objects.filter(
|
|
pedimento_id=pedimento_id,
|
|
organizacion=organizacion
|
|
))
|
|
|
|
# Procesar cada archivo
|
|
espacio_usado_temp = espacio_inicial
|
|
|
|
for file in files:
|
|
try:
|
|
# Validaciones por archivo
|
|
if not file.name:
|
|
failed_files.append("archivo_sin_nombre")
|
|
errors.append("Archivo sin nombre detectado")
|
|
continue
|
|
|
|
# Tipo por archivo: en el apartado Pedimento se clasifica el XML por
|
|
# contenido y se renombra a la nomenclatura canónica vu_PC_/vu_RM_
|
|
file_document_type = document_type
|
|
tipo_explicito = bool(document_type_id_param)
|
|
if tab_seccion == 'pedimento':
|
|
try:
|
|
type_id, nombre_canonico = clasificar_xml_apartado_pedimento(file, pedimento)
|
|
file_document_type = DocumentType.objects.get(id=type_id)
|
|
except ValueError as e:
|
|
failed_files.append(file.name)
|
|
errors.append(str(e))
|
|
continue
|
|
except DocumentType.DoesNotExist:
|
|
failed_files.append(file.name)
|
|
errors.append(
|
|
f"'{file.name}': el tipo de documento requerido no existe en el catálogo. Por favor, créelo primero."
|
|
)
|
|
continue
|
|
file.name = nombre_canonico
|
|
tipo_explicito = True
|
|
|
|
# Obtener extensión del archivo
|
|
extension = file.name.split('.')[-1].lower() if '.' in file.name else ''
|
|
|
|
# Detectar si ya existe un documento con el mismo nombre base + extensión.
|
|
# storage_service agrega un sufijo UUID de 8 chars al guardar, hay que ignorarlo.
|
|
new_name_base = re.sub(r'_[a-zA-Z0-9]{8}$', '', os.path.splitext(file.name)[0]).lower().strip('_')
|
|
existing_doc = None
|
|
|
|
# En el apartado Pedimento el reemplazo es por tipo: solo debe existir
|
|
# un Pedimento Completo y una Remesa por pedimento
|
|
if tab_seccion == 'pedimento':
|
|
for doc in existing_docs:
|
|
if doc.document_type_id == file_document_type.id:
|
|
existing_doc = doc
|
|
break
|
|
|
|
if existing_doc is None:
|
|
for doc in existing_docs:
|
|
if doc.archivo:
|
|
doc_basename = os.path.basename(doc.archivo.name)
|
|
doc_base = re.sub(r'_[a-zA-Z0-9]{8}$', '', os.path.splitext(doc_basename)[0]).lower().strip('_')
|
|
doc_ext = (doc.extension or '').lower()
|
|
if new_name_base == doc_base and extension == doc_ext:
|
|
existing_doc = doc
|
|
break
|
|
|
|
if existing_doc:
|
|
# Reemplazar archivo del documento existente
|
|
if existing_doc.archivo:
|
|
storage_service.delete_file(existing_doc.archivo.name)
|
|
ruta = storage_service.save_document(
|
|
file=file,
|
|
organizacion_id=organizacion.id,
|
|
pedimento_app=pedimento.pedimento_app,
|
|
metadata={'source': 'bulk_upload_replace'}
|
|
)
|
|
if ruta:
|
|
existing_doc.archivo = ruta
|
|
existing_doc.size = file.size
|
|
existing_doc.extension = extension
|
|
# Conservar el tipo del documento existente salvo que el
|
|
# request lo defina explícitamente (no degradar docs VU)
|
|
if tipo_explicito:
|
|
existing_doc.document_type = file_document_type
|
|
existing_doc.save()
|
|
else:
|
|
raise Exception(f"Error al guardar archivo: {file.name}")
|
|
document = existing_doc
|
|
replaced_count += 1
|
|
was_replaced = True
|
|
else:
|
|
# Crear nuevo documento
|
|
document = Document.objects.create(
|
|
organizacion=organizacion,
|
|
pedimento_id=pedimento_id,
|
|
document_type=file_document_type,
|
|
size=file.size,
|
|
extension=extension
|
|
)
|
|
ruta = storage_service.save_document(
|
|
file=file,
|
|
organizacion_id=organizacion.id,
|
|
pedimento_app=pedimento.pedimento_app,
|
|
metadata={'source': 'bulk_upload'}
|
|
)
|
|
if ruta:
|
|
document.archivo = ruta
|
|
document.save()
|
|
else:
|
|
document.delete()
|
|
raise Exception(f"Error al guardar archivo: {file.name}")
|
|
created_count += 1
|
|
was_replaced = False
|
|
# Visible para detección de duplicados de archivos posteriores del mismo lote
|
|
existing_docs.append(document)
|
|
|
|
# Actualizar espacio usado
|
|
espacio_usado_temp += file.size
|
|
total_space_used += file.size
|
|
|
|
uploaded_documents.append({
|
|
"id": str(document.id),
|
|
"filename": file.name,
|
|
"size": file.size,
|
|
"extension": extension,
|
|
"document_type": document.document_type.nombre if document.document_type else None,
|
|
"replaced": was_replaced,
|
|
})
|
|
|
|
except Exception as e:
|
|
failed_files.append(file.name)
|
|
errors.append(f"Error al procesar {file.name}: {str(e)}")
|
|
continue
|
|
|
|
# Actualizar el uso de almacenamiento final
|
|
uso.espacio_utilizado = espacio_usado_temp
|
|
uso.save()
|
|
|
|
except Exception as e:
|
|
return Response(
|
|
{"error": f"Error durante el procesamiento masivo: {str(e)}"},
|
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
|
)
|
|
|
|
# Convertir bytes a MB para la respuesta
|
|
space_used_mb = round(total_space_used / (1024 * 1024), 2)
|
|
|
|
# Preparar respuesta
|
|
partes = []
|
|
if created_count:
|
|
partes.append(f"{created_count} documento(s) creado(s) exitosamente")
|
|
if replaced_count:
|
|
partes.append(f"{replaced_count} documento(s) reemplazado(s) exitosamente")
|
|
mensaje_exito = " y ".join(partes) if partes else "Sin cambios"
|
|
|
|
response_data = {
|
|
"uploaded_count": len(uploaded_documents),
|
|
"created_count": created_count,
|
|
"replaced_count": replaced_count,
|
|
"uploaded_documents": uploaded_documents,
|
|
"space_used_mb": space_used_mb,
|
|
"pedimento_id": str(pedimento_id),
|
|
"document_type": document_type.nombre,
|
|
}
|
|
|
|
if failed_files:
|
|
if uploaded_documents:
|
|
mensaje_fallo = f"Algunos documentos no pudieron ser subidos. {mensaje_exito}"
|
|
else:
|
|
mensaje_fallo = "No fue posible subir ningún documento"
|
|
response_data.update({
|
|
"message": mensaje_fallo,
|
|
"failed_files": failed_files,
|
|
"errors": errors,
|
|
})
|
|
response_status = status.HTTP_207_MULTI_STATUS
|
|
else:
|
|
response_data["message"] = mensaje_exito
|
|
response_status = status.HTTP_201_CREATED
|
|
|
|
return Response(response_data, status=response_status)
|
|
|
|
@action(detail=False, methods=['post'], url_path='bulk-upload-vu', parser_classes=[MultiPartParser])
|
|
def bulk_upload_vu(self, request):
|
|
"""
|
|
Endpoint para subir múltiples documentos a un pedimento específico.
|
|
|
|
FormData esperado:
|
|
- pedimento_id: UUID del pedimento (requerido)
|
|
- files: Lista de archivos a subir (requerido)
|
|
|
|
Nota: Se usa automáticamente el tipo de documento "Documento General"
|
|
|
|
Respuesta exitosa:
|
|
{
|
|
"message": "Documentos subidos exitosamente",
|
|
"uploaded_count": 5,
|
|
"uploaded_documents": [
|
|
{
|
|
"id": "uuid1",
|
|
"filename": "documento1.pdf",
|
|
"size": 1024000,
|
|
"extension": "pdf"
|
|
},
|
|
...
|
|
],
|
|
"space_used_mb": 25.6,
|
|
"failed_files": [],
|
|
"errors": []
|
|
}
|
|
|
|
Respuesta con errores:
|
|
{
|
|
"message": "Algunos documentos no pudieron ser subidos",
|
|
"uploaded_count": 3,
|
|
"uploaded_documents": [...],
|
|
"space_used_mb": 15.2,
|
|
"failed_files": ["archivo4.pdf", "archivo5.doc"],
|
|
"errors": ["Archivo demasiado grande: archivo4.pdf", "Tipo de archivo no soportado: archivo5.doc"]
|
|
}
|
|
"""
|
|
|
|
# Validar datos requeridos
|
|
pedimento_id = request.data.get('pedimento_id')
|
|
if not pedimento_id:
|
|
return Response(
|
|
{"error": "Se requiere el campo 'pedimento_id'"},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
tab_seccion = request.data.get('tab_seccion')
|
|
if not tab_seccion:
|
|
return Response(
|
|
{"error": "Se requiere el campo 'tab_seccion'"},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
numero_documento = request.data.get('numero')
|
|
if not numero_documento:
|
|
return Response(
|
|
{"error": "Se requiere el campo 'numero'"},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
files = request.FILES.getlist('files')
|
|
if not files:
|
|
return Response(
|
|
{"error": "Se requiere al menos un archivo para subir"},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
# Validar usuario autenticado
|
|
if not request.user.is_authenticated:
|
|
return Response(
|
|
{"error": "Usuario no autenticado"},
|
|
status=status.HTTP_401_UNAUTHORIZED
|
|
)
|
|
|
|
# Obtener el pedimento primero para usar su organización
|
|
from api.customs.models import Pedimento
|
|
try:
|
|
pedimento = Pedimento.objects.get(id=pedimento_id)
|
|
except Pedimento.DoesNotExist:
|
|
return Response(
|
|
{"error": "Pedimento no encontrado"},
|
|
status=status.HTTP_404_NOT_FOUND
|
|
)
|
|
|
|
# Usar la organización del pedimento
|
|
organizacion = pedimento.organizacion
|
|
|
|
# Validar que el usuario tenga permisos para esta organización
|
|
if not request.user.is_superuser:
|
|
if not hasattr(request.user, 'organizacion') or request.user.organizacion != organizacion:
|
|
return Response(
|
|
{"error": "No tienes permisos para subir documentos a este pedimento"},
|
|
status=status.HTTP_403_FORBIDDEN
|
|
)
|
|
|
|
uploaded_documents = []
|
|
failed_files = []
|
|
errors = []
|
|
total_space_used = 0
|
|
|
|
try:
|
|
with transaction.atomic():
|
|
# Obtener uso de almacenamiento
|
|
uso = UsoAlmacenamiento.objects.select_for_update().get_or_create(
|
|
organizacion=organizacion,
|
|
defaults={'espacio_utilizado': 0}
|
|
)[0]
|
|
|
|
# Calcular límites
|
|
max_almacenamiento_bytes = organizacion.licencia.almacenamiento * 1024 ** 3
|
|
espacio_inicial = uso.espacio_utilizado
|
|
|
|
# Calcular el tamaño total de todos los archivos
|
|
total_files_size = sum(file.size for file in files)
|
|
nuevo_espacio_total = espacio_inicial + total_files_size
|
|
|
|
# Validar que hay espacio suficiente para todos los archivos
|
|
if nuevo_espacio_total > max_almacenamiento_bytes:
|
|
espacio_faltante = nuevo_espacio_total - max_almacenamiento_bytes
|
|
return Response({
|
|
"error": "Espacio de almacenamiento insuficiente para todos los archivos",
|
|
"detalle": {
|
|
"espacio_faltante_gb": round(espacio_faltante / (1024 ** 3), 2),
|
|
"espacio_utilizado_gb": round(espacio_inicial / (1024 ** 3), 2),
|
|
"limite_gb": organizacion.licencia.almacenamiento,
|
|
"archivos_gb": round(total_files_size / (1024 ** 3), 4),
|
|
"total_archivos": len(files)
|
|
},
|
|
"codigo": "bulk_storage_limit_exceeded"
|
|
}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
# Procesar cada archivo
|
|
espacio_usado_temp = espacio_inicial
|
|
|
|
for file in files:
|
|
try:
|
|
|
|
nuevo_nombre = file.name
|
|
|
|
# Validaciones por archivo
|
|
if not file.name:
|
|
failed_files.append("archivo_sin_nombre")
|
|
errors.append("Archivo sin nombre detectado")
|
|
continue
|
|
|
|
# secciones = file.name.split('.')[-1].lower() if '.' in file.name else ''
|
|
|
|
filename = file.name
|
|
if '.' in filename:
|
|
base = '.'.join(filename.split('.')[:-1]) # todo excepto la última parte
|
|
secciones = filename.split('.')[-1] # la última “extensión” / flag
|
|
else:
|
|
base = filename
|
|
secciones = ""
|
|
|
|
file.name = base
|
|
|
|
# Obtener extensión del archivo
|
|
extension = file.name.split('.')[-1].lower() if '.' in file.name else ''
|
|
|
|
if tab_seccion == 'partida':
|
|
|
|
# Construir nombre nuevo
|
|
nuevo_nombre = f"vu_PT_{pedimento.pedimento_app}_{numero_documento}.{extension}"
|
|
|
|
# Usar tipo de documento por defecto siempre
|
|
document_type, created = DocumentType.objects.get_or_create(
|
|
nombre="Pedimento Partida",
|
|
defaults={'descripcion': "Tag para saber que el archivo guarda una partida"}
|
|
)
|
|
|
|
elif tab_seccion == 'cove':
|
|
|
|
if secciones == 'general':
|
|
nuevo_nombre = f"vu_COVE_{pedimento.pedimento_app}_{numero_documento}.{extension}"
|
|
# Usar tipo de documento por defecto siempre
|
|
document_type, created = DocumentType.objects.get_or_create(
|
|
nombre="Cove",
|
|
defaults={'descripcion': "Tag para saber que el archivo guarda un cove"}
|
|
)
|
|
elif secciones == 'acuse':
|
|
nuevo_nombre = f"vu_AC_COVE_{pedimento.pedimento_app}_{numero_documento}.{extension}"
|
|
# Usar tipo de documento por defecto siempre
|
|
document_type, created = DocumentType.objects.get_or_create(
|
|
nombre="Acuse Cove",
|
|
defaults={'descripcion': "Tag para saber que el archivo guarda un acuse de cove"}
|
|
)
|
|
else:
|
|
# Usar tipo de documento por defecto siempre
|
|
document_type, created = DocumentType.objects.get_or_create(
|
|
nombre="Documento General",
|
|
defaults={'descripcion': "Documento general sin tipo específico"}
|
|
)
|
|
|
|
elif tab_seccion == 'edoc':
|
|
|
|
if secciones == 'general':
|
|
nuevo_nombre = f"vu_ED_{pedimento.pedimento_app}_{numero_documento}.{extension}"
|
|
# Usar tipo de documento por defecto siempre
|
|
document_type, created = DocumentType.objects.get_or_create(
|
|
nombre="Pedimento EDocument",
|
|
defaults={'descripcion': "Tag para saber que el documento es un EDocument"}
|
|
)
|
|
elif secciones == 'acuse':
|
|
nuevo_nombre = f"vu_AC_{pedimento.pedimento_app}_{numero_documento}.{extension}"
|
|
# Usar tipo de documento por defecto siempre
|
|
document_type, created = DocumentType.objects.get_or_create(
|
|
nombre="Pedimento Acuse",
|
|
defaults={'descripcion': "Tag para saber que el documento es un Acuse"}
|
|
)
|
|
else:
|
|
# Usar tipo de documento por defecto siempre
|
|
document_type, created = DocumentType.objects.get_or_create(
|
|
nombre="Documento General",
|
|
defaults={'descripcion': "Documento general sin tipo específico"}
|
|
)
|
|
else:
|
|
failed_files.append("archivo_sin_seccion")
|
|
errors.append("Archivo sin seccion")
|
|
continue
|
|
|
|
# Renombrar archivo
|
|
file.name = nuevo_nombre
|
|
|
|
# Crear el documento
|
|
document = Document.objects.create(
|
|
organizacion=organizacion,
|
|
pedimento_id=pedimento_id,
|
|
document_type=document_type,
|
|
size=file.size,
|
|
extension=extension
|
|
)
|
|
|
|
ruta = storage_service.save_document(
|
|
file=file,
|
|
organizacion_id=organizacion.id,
|
|
pedimento_app=pedimento.pedimento_app,
|
|
metadata={'source': 'bulk_upload'}
|
|
)
|
|
|
|
if ruta:
|
|
document.archivo = ruta
|
|
document.save()
|
|
else:
|
|
document.delete()
|
|
raise Exception(f"Error al guardar archivo: {file.name}")
|
|
|
|
# Actualizar espacio usado
|
|
espacio_usado_temp += file.size
|
|
total_space_used += file.size
|
|
|
|
uploaded_documents.append({
|
|
"id": str(document.id),
|
|
"filename": file.name,
|
|
"size": file.size,
|
|
"extension": extension,
|
|
"document_type": document_type.nombre
|
|
})
|
|
|
|
except Exception as e:
|
|
failed_files.append(file.name)
|
|
errors.append(f"Error al procesar {file.name}: {str(e)}")
|
|
continue
|
|
|
|
# Actualizar el uso de almacenamiento final
|
|
uso.espacio_utilizado = espacio_usado_temp
|
|
uso.save()
|
|
|
|
except Exception as e:
|
|
return Response(
|
|
{"error": f"Error durante el procesamiento masivo: {str(e)}"},
|
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
|
)
|
|
|
|
# Convertir bytes a MB para la respuesta
|
|
space_used_mb = round(total_space_used / (1024 * 1024), 2)
|
|
|
|
# Preparar respuesta
|
|
response_data = {
|
|
"uploaded_count": len(uploaded_documents),
|
|
"uploaded_documents": uploaded_documents,
|
|
"space_used_mb": space_used_mb,
|
|
"pedimento_id": str(pedimento_id),
|
|
"document_type": document_type.nombre
|
|
}
|
|
|
|
if failed_files:
|
|
response_data.update({
|
|
"message": "Algunos documentos no pudieron ser subidos",
|
|
"failed_files": failed_files,
|
|
"errors": errors
|
|
})
|
|
response_status = status.HTTP_207_MULTI_STATUS
|
|
else:
|
|
response_data["message"] = "Documentos subidos exitosamente"
|
|
response_status = status.HTTP_201_CREATED
|
|
|
|
return Response(response_data, status=response_status)
|
|
|
|
@action(detail=False, methods=['post'], url_path='create-vu-record', parser_classes=[MultiPartParser])
|
|
def create_vu_record(self, request):
|
|
"""
|
|
Crea un registro (Partida/Cove/EDocument) en su tabla correspondiente
|
|
y sube sus archivos con la nomenclatura VU.
|
|
|
|
FormData:
|
|
- pedimento_id : UUID del pedimento
|
|
- tab_seccion : 'partida' | 'cove' | 'edoc'
|
|
- numero : número del registro a crear
|
|
- files : archivos (nombre con flag de sección: .xml.general, .pdf.acuse, etc.)
|
|
"""
|
|
pedimento_id = request.data.get('pedimento_id')
|
|
tab_seccion = request.data.get('tab_seccion')
|
|
numero = request.data.get('numero', '').strip()
|
|
files = request.FILES.getlist('files')
|
|
|
|
if not pedimento_id:
|
|
return Response({"error": "Se requiere 'pedimento_id'"}, status=status.HTTP_400_BAD_REQUEST)
|
|
if tab_seccion not in ('partida', 'cove', 'edoc'):
|
|
return Response({"error": "tab_seccion debe ser 'partida', 'cove' o 'edoc'"}, status=status.HTTP_400_BAD_REQUEST)
|
|
if not numero:
|
|
return Response({"error": "Se requiere 'numero'"}, status=status.HTTP_400_BAD_REQUEST)
|
|
if not files:
|
|
return Response({"error": "Se requiere al menos un archivo"}, status=status.HTTP_400_BAD_REQUEST)
|
|
if not request.user.is_authenticated:
|
|
return Response({"error": "Usuario no autenticado"}, status=status.HTTP_401_UNAUTHORIZED)
|
|
|
|
from api.customs.models import Pedimento as PedimentoModel, Partida, Cove, EDocument, EstadoDescarga
|
|
try:
|
|
pedimento = PedimentoModel.objects.get(id=pedimento_id)
|
|
except PedimentoModel.DoesNotExist:
|
|
return Response({"error": "Pedimento no encontrado"}, status=status.HTTP_404_NOT_FOUND)
|
|
|
|
organizacion = pedimento.organizacion
|
|
if not request.user.is_superuser:
|
|
if not hasattr(request.user, 'organizacion') or request.user.organizacion != organizacion:
|
|
return Response({"error": "Sin permisos para este pedimento"}, status=status.HTTP_403_FORBIDDEN)
|
|
|
|
# Validar número entero para partida
|
|
numero_int = None
|
|
if tab_seccion == 'partida':
|
|
try:
|
|
numero_int = int(numero)
|
|
except ValueError:
|
|
return Response({"error": "El número de partida debe ser un entero"}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
uploaded_documents = []
|
|
failed_files = []
|
|
errors = []
|
|
total_space_used = 0
|
|
expediente_obj = None
|
|
|
|
try:
|
|
with transaction.atomic():
|
|
uso = UsoAlmacenamiento.objects.select_for_update().get_or_create(
|
|
organizacion=organizacion,
|
|
defaults={'espacio_utilizado': 0}
|
|
)[0]
|
|
|
|
max_bytes = organizacion.licencia.almacenamiento * 1024 ** 3
|
|
total_files_size = sum(f.size for f in files)
|
|
if uso.espacio_utilizado + total_files_size > max_bytes:
|
|
espacio_faltante = (uso.espacio_utilizado + total_files_size) - max_bytes
|
|
return Response({
|
|
"error": "Espacio de almacenamiento insuficiente",
|
|
"espacio_faltante_gb": round(espacio_faltante / (1024 ** 3), 2),
|
|
}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
# Verificar unicidad y crear registro
|
|
if tab_seccion == 'partida':
|
|
if Partida.objects.filter(pedimento=pedimento, numero_partida=numero_int).exists():
|
|
return Response(
|
|
{"error": f"La partida {numero} ya existe para este pedimento"},
|
|
status=status.HTTP_409_CONFLICT
|
|
)
|
|
expediente_obj = Partida.objects.create(
|
|
pedimento=pedimento,
|
|
organizacion=organizacion,
|
|
numero_partida=numero_int
|
|
)
|
|
elif tab_seccion == 'cove':
|
|
if Cove.objects.filter(pedimento=pedimento, numero_cove=numero).exists():
|
|
return Response(
|
|
{"error": f"El COVE {numero} ya existe para este pedimento"},
|
|
status=status.HTTP_409_CONFLICT
|
|
)
|
|
expediente_obj = Cove.objects.create(
|
|
pedimento=pedimento,
|
|
organizacion=organizacion,
|
|
numero_cove=numero
|
|
)
|
|
elif tab_seccion == 'edoc':
|
|
if EDocument.objects.filter(pedimento=pedimento, numero_edocument=numero).exists():
|
|
return Response(
|
|
{"error": f"El EDocument {numero} ya existe para este pedimento"},
|
|
status=status.HTTP_409_CONFLICT
|
|
)
|
|
expediente_obj = EDocument.objects.create(
|
|
pedimento=pedimento,
|
|
organizacion=organizacion,
|
|
numero_edocument=numero
|
|
)
|
|
|
|
espacio_usado_temp = uso.espacio_utilizado
|
|
uploaded_secciones = set()
|
|
|
|
for file in files:
|
|
try:
|
|
if not file.name:
|
|
failed_files.append("archivo_sin_nombre")
|
|
errors.append("Archivo sin nombre detectado")
|
|
continue
|
|
|
|
filename = file.name
|
|
if '.' in filename:
|
|
base = '.'.join(filename.split('.')[:-1])
|
|
secciones = filename.split('.')[-1]
|
|
else:
|
|
base = filename
|
|
secciones = ''
|
|
|
|
file.name = base
|
|
extension = file.name.split('.')[-1].lower() if '.' in file.name else ''
|
|
|
|
if tab_seccion == 'partida':
|
|
nuevo_nombre = f"vu_PT_{pedimento.pedimento_app}_{numero}.{extension}"
|
|
document_type, _ = DocumentType.objects.get_or_create(
|
|
nombre="Pedimento Partida",
|
|
defaults={'descripcion': "Tag para saber que el archivo guarda una partida"}
|
|
)
|
|
elif tab_seccion == 'cove':
|
|
if secciones == 'acuse':
|
|
nuevo_nombre = f"vu_AC_COVE_{pedimento.pedimento_app}_{numero}.{extension}"
|
|
document_type, _ = DocumentType.objects.get_or_create(
|
|
nombre="Acuse Cove",
|
|
defaults={'descripcion': "Tag para saber que el archivo guarda un acuse de cove"}
|
|
)
|
|
else:
|
|
nuevo_nombre = f"vu_COVE_{pedimento.pedimento_app}_{numero}.{extension}"
|
|
document_type, _ = DocumentType.objects.get_or_create(
|
|
nombre="Cove",
|
|
defaults={'descripcion': "Tag para saber que el archivo guarda un cove"}
|
|
)
|
|
elif tab_seccion == 'edoc':
|
|
if secciones == 'acuse':
|
|
nuevo_nombre = f"vu_AC_{pedimento.pedimento_app}_{numero}.{extension}"
|
|
document_type, _ = DocumentType.objects.get_or_create(
|
|
nombre="Pedimento Acuse",
|
|
defaults={'descripcion': "Tag para saber que el documento es un Acuse"}
|
|
)
|
|
else:
|
|
nuevo_nombre = f"vu_ED_{pedimento.pedimento_app}_{numero}.{extension}"
|
|
document_type, _ = DocumentType.objects.get_or_create(
|
|
nombre="Pedimento EDocument",
|
|
defaults={'descripcion': "Tag para saber que el documento es un EDocument"}
|
|
)
|
|
|
|
file.name = nuevo_nombre
|
|
|
|
document = Document.objects.create(
|
|
organizacion=organizacion,
|
|
pedimento_id=pedimento_id,
|
|
document_type=document_type,
|
|
size=file.size,
|
|
extension=extension
|
|
)
|
|
|
|
ruta = storage_service.save_document(
|
|
file=file,
|
|
organizacion_id=organizacion.id,
|
|
pedimento_app=pedimento.pedimento_app,
|
|
metadata={'source': 'create_vu_record'}
|
|
)
|
|
|
|
if ruta:
|
|
# Confirmar que el archivo quedó físicamente en storage antes
|
|
# de contar la sección como subida (T2026-05-027): nunca marcar
|
|
# descargado sin archivo verificado
|
|
if not storage_service.file_exists(ruta):
|
|
document.delete()
|
|
raise Exception(f"El archivo no se encuentra en storage tras guardarlo: {file.name}")
|
|
document.archivo = ruta
|
|
document.save()
|
|
else:
|
|
document.delete()
|
|
raise Exception(f"Error al guardar archivo: {file.name}")
|
|
|
|
espacio_usado_temp += file.size
|
|
total_space_used += file.size
|
|
uploaded_secciones.add(secciones)
|
|
|
|
uploaded_documents.append({
|
|
"id": str(document.id),
|
|
"filename": file.name,
|
|
"size": file.size,
|
|
"extension": extension,
|
|
"document_type": document_type.nombre
|
|
})
|
|
|
|
except Exception as e:
|
|
failed_files.append(file.name)
|
|
errors.append(f"Error al procesar {file.name}: {str(e)}")
|
|
continue
|
|
|
|
# Actualizar estados de descarga según secciones subidas y verificadas
|
|
# en storage; el modelo deriva los booleanos legados del estado
|
|
if tab_seccion == 'partida':
|
|
if uploaded_secciones:
|
|
expediente_obj.descargado = True
|
|
expediente_obj.save(update_fields=['descargado'])
|
|
elif tab_seccion == 'cove':
|
|
update_fields = []
|
|
if 'general' in uploaded_secciones:
|
|
expediente_obj.cove_estado = EstadoDescarga.DESCARGADO
|
|
update_fields.append('cove_estado')
|
|
if 'acuse' in uploaded_secciones:
|
|
expediente_obj.acuse_cove_estado = EstadoDescarga.DESCARGADO
|
|
update_fields.append('acuse_cove_estado')
|
|
if update_fields:
|
|
expediente_obj.save(update_fields=update_fields)
|
|
elif tab_seccion == 'edoc':
|
|
update_fields = []
|
|
if 'general' in uploaded_secciones:
|
|
expediente_obj.edocument_estado = EstadoDescarga.DESCARGADO
|
|
update_fields.append('edocument_estado')
|
|
if 'acuse' in uploaded_secciones:
|
|
expediente_obj.acuse_estado = EstadoDescarga.DESCARGADO
|
|
update_fields.append('acuse_estado')
|
|
if update_fields:
|
|
expediente_obj.save(update_fields=update_fields)
|
|
|
|
uso.espacio_utilizado = espacio_usado_temp
|
|
uso.save()
|
|
|
|
except Exception as e:
|
|
return Response(
|
|
{"error": f"Error durante el procesamiento: {str(e)}"},
|
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
|
)
|
|
|
|
space_used_mb = round(total_space_used / (1024 * 1024), 2)
|
|
response_data = {
|
|
"uploaded_count": len(uploaded_documents),
|
|
"uploaded_documents": uploaded_documents,
|
|
"space_used_mb": space_used_mb,
|
|
"pedimento_id": str(pedimento_id),
|
|
"expediente_id": str(expediente_obj.id),
|
|
"tab_seccion": tab_seccion,
|
|
"numero": numero,
|
|
}
|
|
|
|
if failed_files:
|
|
response_data.update({
|
|
"message": f"Registro creado pero algunos archivos fallaron",
|
|
"failed_files": failed_files,
|
|
"errors": errors
|
|
})
|
|
response_status = status.HTTP_207_MULTI_STATUS
|
|
else:
|
|
response_data["message"] = f"{tab_seccion.capitalize()} {numero} creado exitosamente"
|
|
response_status = status.HTTP_201_CREATED
|
|
|
|
return Response(response_data, status=response_status)
|
|
|
|
@action(detail=False, methods=['post'], url_path='bulk-download-partidas-vu')
|
|
def bulk_download_partidas_vu(self, request):
|
|
from ..customs.models import Partida
|
|
import tempfile
|
|
|
|
ids_partidas = request.data.get('ids', [])
|
|
if not ids_partidas:
|
|
return Response({"error": "Se requiere una lista de IDs"}, status=status.HTTP_400_BAD_REQUEST)
|
|
if not isinstance(ids_partidas, list):
|
|
return Response({"error": "El campo 'ids' debe ser una lista"}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
partidas = Partida.objects.filter(id__in=ids_partidas).select_related('pedimento')
|
|
if not partidas.exists():
|
|
return Response({"error": "No se encontraron partidas"}, status=status.HTTP_404_NOT_FOUND)
|
|
|
|
doc_ids = []
|
|
for partida in partidas:
|
|
docs = Document.objects.filter(
|
|
pedimento_id=partida.pedimento.id,
|
|
archivo__icontains=f'vu_pt_{partida.pedimento.pedimento_app}_{partida.numero_partida}_'
|
|
).values_list('id', flat=True)
|
|
doc_ids.extend(docs)
|
|
|
|
queryset = self.get_queryset()
|
|
docs_qs = queryset.filter(id__in=doc_ids)
|
|
if not docs_qs.exists():
|
|
return Response({"error": "No se encontraron documentos para las partidas seleccionadas"}, status=status.HTTP_404_NOT_FOUND)
|
|
|
|
buffer = BytesIO()
|
|
temp_files = []
|
|
try:
|
|
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
|
for doc in docs_qs:
|
|
if not doc.archivo:
|
|
continue
|
|
ruta = str(doc.archivo)
|
|
if not storage_service.file_exists(ruta):
|
|
continue
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix='.tmp') as tmp:
|
|
tmp_path = tmp.name
|
|
temp_files.append(tmp_path)
|
|
if not storage_service.download_file(ruta, tmp_path):
|
|
continue
|
|
nombre = ruta.rsplit('/', 1)[-1]
|
|
with open(tmp_path, 'rb') as f:
|
|
zip_file.writestr(nombre, f.read())
|
|
buffer.seek(0)
|
|
response = HttpResponse(buffer, content_type='application/zip')
|
|
response['Content-Disposition'] = f'attachment; filename=partidas_vu_{len(ids_partidas)}.zip'
|
|
return response
|
|
except Exception as e:
|
|
return Response({"error": f"Error al crear ZIP: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
finally:
|
|
for tmp_path in temp_files:
|
|
try:
|
|
if os.path.exists(tmp_path):
|
|
os.unlink(tmp_path)
|
|
except Exception:
|
|
pass
|
|
|
|
@action(detail=False, methods=['post'], url_path='bulk-download-coves-vu')
|
|
def bulk_download_coves_vu(self, request):
|
|
from ..customs.models import Cove
|
|
import tempfile
|
|
|
|
ids_coves = request.data.get('ids', [])
|
|
if not ids_coves:
|
|
return Response({"error": "Se requiere una lista de IDs"}, status=status.HTTP_400_BAD_REQUEST)
|
|
if not isinstance(ids_coves, list):
|
|
return Response({"error": "El campo 'ids' debe ser una lista"}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
coves = Cove.objects.filter(id__in=ids_coves).select_related('pedimento')
|
|
if not coves.exists():
|
|
return Response({"error": "No se encontraron COVEs"}, status=status.HTTP_404_NOT_FOUND)
|
|
|
|
doc_ids = []
|
|
for cove in coves:
|
|
docs = Document.objects.filter(
|
|
pedimento_id=cove.pedimento.id,
|
|
archivo__icontains=cove.numero_cove
|
|
).values_list('id', flat=True)
|
|
doc_ids.extend(docs)
|
|
|
|
queryset = self.get_queryset()
|
|
docs_qs = queryset.filter(id__in=doc_ids)
|
|
if not docs_qs.exists():
|
|
return Response({"error": "No se encontraron documentos para los COVEs seleccionados"}, status=status.HTTP_404_NOT_FOUND)
|
|
|
|
buffer = BytesIO()
|
|
temp_files = []
|
|
try:
|
|
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
|
for doc in docs_qs:
|
|
if not doc.archivo:
|
|
continue
|
|
ruta = str(doc.archivo)
|
|
if not storage_service.file_exists(ruta):
|
|
continue
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix='.tmp') as tmp:
|
|
tmp_path = tmp.name
|
|
temp_files.append(tmp_path)
|
|
if not storage_service.download_file(ruta, tmp_path):
|
|
continue
|
|
nombre = ruta.rsplit('/', 1)[-1]
|
|
with open(tmp_path, 'rb') as f:
|
|
zip_file.writestr(nombre, f.read())
|
|
buffer.seek(0)
|
|
response = HttpResponse(buffer, content_type='application/zip')
|
|
response['Content-Disposition'] = f'attachment; filename=coves_vu_{len(ids_coves)}.zip'
|
|
return response
|
|
except Exception as e:
|
|
return Response({"error": f"Error al crear ZIP: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
finally:
|
|
for tmp_path in temp_files:
|
|
try:
|
|
if os.path.exists(tmp_path):
|
|
os.unlink(tmp_path)
|
|
except Exception:
|
|
pass
|
|
|
|
@action(detail=False, methods=['post'], url_path='bulk-download-edocs-vu')
|
|
def bulk_download_edocs_vu(self, request):
|
|
from ..customs.models import EDocument
|
|
import tempfile
|
|
|
|
ids_edocs = request.data.get('ids', [])
|
|
if not ids_edocs:
|
|
return Response({"error": "Se requiere una lista de IDs"}, status=status.HTTP_400_BAD_REQUEST)
|
|
if not isinstance(ids_edocs, list):
|
|
return Response({"error": "El campo 'ids' debe ser una lista"}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
edocs = EDocument.objects.filter(id__in=ids_edocs).select_related('pedimento')
|
|
if not edocs.exists():
|
|
return Response({"error": "No se encontraron EDocuments"}, status=status.HTTP_404_NOT_FOUND)
|
|
|
|
doc_ids = []
|
|
for edoc in edocs:
|
|
docs = Document.objects.filter(
|
|
pedimento_id=edoc.pedimento.id,
|
|
archivo__icontains=edoc.numero_edocument
|
|
).values_list('id', flat=True)
|
|
doc_ids.extend(docs)
|
|
|
|
queryset = self.get_queryset()
|
|
docs_qs = queryset.filter(id__in=doc_ids)
|
|
if not docs_qs.exists():
|
|
return Response({"error": "No se encontraron documentos para los EDocuments seleccionados"}, status=status.HTTP_404_NOT_FOUND)
|
|
|
|
buffer = BytesIO()
|
|
temp_files = []
|
|
try:
|
|
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
|
for doc in docs_qs:
|
|
if not doc.archivo:
|
|
continue
|
|
ruta = str(doc.archivo)
|
|
if not storage_service.file_exists(ruta):
|
|
continue
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix='.tmp') as tmp:
|
|
tmp_path = tmp.name
|
|
temp_files.append(tmp_path)
|
|
if not storage_service.download_file(ruta, tmp_path):
|
|
continue
|
|
nombre = ruta.rsplit('/', 1)[-1]
|
|
with open(tmp_path, 'rb') as f:
|
|
zip_file.writestr(nombre, f.read())
|
|
buffer.seek(0)
|
|
response = HttpResponse(buffer, content_type='application/zip')
|
|
response['Content-Disposition'] = f'attachment; filename=edocs_vu_{len(ids_edocs)}.zip'
|
|
return response
|
|
except Exception as e:
|
|
return Response({"error": f"Error al crear ZIP: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
finally:
|
|
for tmp_path in temp_files:
|
|
try:
|
|
if os.path.exists(tmp_path):
|
|
os.unlink(tmp_path)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
class ProtectedDocumentDownloadView(APIView, DocumentosFiltradosMixin):
|
|
permission_classes = [IsAuthenticated, require_permission('documentos.download')]
|
|
serializer_class = DocumentSerializer
|
|
model = Document
|
|
my_tags = ['Documents']
|
|
|
|
def get_queryset(self):
|
|
return self.get_queryset_filtrado_por_organizacion()
|
|
|
|
def get(self, request, pk):
|
|
import tempfile
|
|
import os
|
|
from api.utils.storage_service import storage_service
|
|
|
|
try:
|
|
doc = Document.objects.get(pk=pk)
|
|
except Document.DoesNotExist:
|
|
raise Http404("Documento no encontrado")
|
|
|
|
org = get_org_context(request.user)
|
|
if doc.organizacion != org:
|
|
raise Http404("No autorizado")
|
|
|
|
if not doc.archivo:
|
|
raise Http404("Documento sin archivo asociado")
|
|
|
|
ruta = str(doc.archivo)
|
|
|
|
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
|
tmp_path = tmp.name
|
|
|
|
success = storage_service.download_file(ruta, tmp_path)
|
|
|
|
if not success:
|
|
raise Http404("No se pudo descargar el archivo")
|
|
|
|
filename = os.path.basename(ruta)
|
|
response = FileResponse(open(tmp_path, 'rb'),as_attachment=True,filename=filename)
|
|
|
|
import atexit
|
|
atexit.register(lambda: os.unlink(tmp_path) if os.path.exists(tmp_path) else None)
|
|
|
|
return response
|
|
|
|
class BulkDownloadZipView(APIView):
|
|
permission_classes = [IsAuthenticated, require_permission('documentos.download')]
|
|
my_tags = ['Documents']
|
|
|
|
def post(self, request):
|
|
import tempfile
|
|
import os
|
|
from api.utils.storage_service import storage_service
|
|
|
|
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
|
|
return Response({"error": "Usuario no autenticado o sin organización"}, status=401)
|
|
|
|
pks = request.data.get('document_ids', [])
|
|
pedimento_nombre = request.data.get('pedimento_nombre', 'documentos')
|
|
if not isinstance(pks, list) or not pks:
|
|
return Response({"error": "Debe proporcionar una lista de IDs de documentos en 'document_ids'."}, status=400)
|
|
|
|
if self.request.user.is_superuser:
|
|
docs = Document.objects.filter(pk__in=pks)
|
|
else:
|
|
docs = Document.objects.filter(pk__in=pks, organizacion=request.user.organizacion)
|
|
|
|
if docs.count() != len(pks):
|
|
return Response({"error": "Uno o más documentos no existen o no pertenecen a su organización."}, status=404)
|
|
|
|
buffer = BytesIO()
|
|
missing_files = []
|
|
temp_files = [] # Para limpiar después
|
|
files_found = []
|
|
|
|
try:
|
|
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
|
for doc in docs:
|
|
if not doc.archivo:
|
|
missing_files.append(f"{doc.id} (sin archivo)")
|
|
continue
|
|
|
|
ruta = str(doc.archivo)
|
|
|
|
# ============ DETECTAR TIPO DE RUTA ============
|
|
is_minio = ruta.startswith('org_')
|
|
|
|
if is_minio:
|
|
# Verificar en MinIO
|
|
if not storage_service.file_exists(ruta):
|
|
missing_files.append(f"{doc.id} ({ruta})")
|
|
continue
|
|
else:
|
|
# Verificar en sistema local
|
|
from pathlib import Path
|
|
from django.conf import settings
|
|
full_path = Path(settings.MEDIA_ROOT) / ruta
|
|
if not full_path.exists():
|
|
missing_files.append(f"{doc.id} ({ruta})")
|
|
continue
|
|
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix='.tmp') as tmp:
|
|
tmp_path = tmp.name
|
|
temp_files.append(tmp_path)
|
|
|
|
if is_minio:
|
|
success = storage_service.download_file(ruta, tmp_path)
|
|
else:
|
|
import shutil
|
|
full_path = Path(settings.MEDIA_ROOT) / ruta
|
|
try:
|
|
shutil.copy2(full_path, tmp_path)
|
|
success = True
|
|
except Exception as e:
|
|
success = False
|
|
|
|
if not success:
|
|
missing_files.append(f"{doc.id} ({ruta})")
|
|
continue
|
|
|
|
files_found.append(f"{doc.id} ({ruta})")
|
|
|
|
file_name = slugify(ruta.rsplit('/', 1)[-1].rsplit('.', 1)[0])
|
|
ext = ruta.split('.')[-1] if '.' in ruta else ''
|
|
zip_name = f"{file_name}.{ext}" if ext else file_name
|
|
|
|
with open(tmp_path, 'rb') as f:
|
|
zip_file.writestr(zip_name, f.read())
|
|
|
|
buffer.seek(0)
|
|
safe_name = slugify(pedimento_nombre)
|
|
response = HttpResponse(buffer, content_type='application/zip')
|
|
response['Content-Disposition'] = f'attachment; filename={safe_name or "documentos"}.zip'
|
|
|
|
if missing_files:
|
|
response['X-Missing-Files'] = ', '.join(missing_files[:5]) # Primeros 5
|
|
response['Access-Control-Expose-Headers'] = 'X-Missing-Files'
|
|
|
|
return response
|
|
|
|
except Exception as e:
|
|
return Response(
|
|
{"error": f"Error al crear el archivo ZIP: {str(e)}"},
|
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
|
)
|
|
finally:
|
|
for tmp_path in temp_files:
|
|
try:
|
|
if os.path.exists(tmp_path):
|
|
os.unlink(tmp_path)
|
|
except Exception as e:
|
|
logger.warning(f"No se pudo eliminar archivo temporal {tmp_path}: {e}")
|
|
|
|
class GetFuenteView(APIView):
|
|
permission_classes = [IsAuthenticated, require_permission('documentos.view')]
|
|
serializer_class = FuenteSerializer
|
|
my_tags = ['Fuente Documentos']
|
|
|
|
def get_queryset(self):
|
|
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
|
|
return Fuente.objects.none()
|
|
return Fuente.objects.all()
|
|
|
|
def get(self, request):
|
|
queryset = self.get_queryset()
|
|
serializer = self.serializer_class(queryset, many=True)
|
|
return Response(serializer.data, status=200)
|
|
|
|
class DocumentTypeView(APIView):
|
|
permission_classes = [IsAuthenticated, require_permission('documentos.view')]
|
|
serializer_class = DocumentTypeSerializer
|
|
my_tags = ['Tipo de Documentos']
|
|
|
|
def get_queryset(self):
|
|
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
|
|
return DocumentType.objects.none()
|
|
return DocumentType.objects.all()
|
|
|
|
def get(self, request):
|
|
queryset = self.get_queryset()
|
|
if not queryset.exists():
|
|
return Response({"detail": "No hay tipos de documento disponibles."}, status=404)
|
|
serializer = self.serializer_class(queryset, many=True)
|
|
return Response(serializer.data, status=200)
|
|
|
|
class ExpedienteZipDownloadView(APIView, DocumentosFiltradosMixin):
|
|
permission_classes = [IsAuthenticated, require_permission('documentos.download')]
|
|
my_tags = ['Documents']
|
|
|
|
def post(self, request):
|
|
"""
|
|
Descarga todos los documentos de un pedimento (o filtrados) en un ZIP.
|
|
Body: { "pedimento_id": "<uuid>" }
|
|
"""
|
|
import tempfile
|
|
import os
|
|
from api.utils.storage_service import storage_service
|
|
|
|
pedimento_id = request.data.get('pedimento_id')
|
|
if not pedimento_id:
|
|
return Response({"error": "Falta pedimento_id"}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
# Validar que el pedimento existe
|
|
try:
|
|
pedimento = Pedimento.objects.get(pk=pedimento_id)
|
|
except Pedimento.DoesNotExist:
|
|
raise Http404("Pedimento no encontrado")
|
|
|
|
# Filtrar documentos del pedimento (y de la org del usuario)
|
|
base_qs = Document.objects.filter(pedimento=pedimento)
|
|
if not request.user.is_superuser:
|
|
if not hasattr(request.user, 'organizacion') or request.user.organizacion != pedimento.organizacion:
|
|
return Response({"error": "No autorizado"}, status=status.HTTP_403_FORBIDDEN)
|
|
base_qs = base_qs.filter(organizacion=request.user.organizacion)
|
|
|
|
docs = base_qs.select_related('pedimento')
|
|
if not docs.exists():
|
|
return Response({"error": "No hay documentos para este pedimento"}, status=status.HTTP_404_NOT_FOUND)
|
|
|
|
buffer = BytesIO()
|
|
missing_files = []
|
|
files_found = []
|
|
temp_files = []
|
|
|
|
try:
|
|
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
|
for doc in docs:
|
|
if not doc.archivo:
|
|
missing_files.append(f"{doc.id} (sin archivo)")
|
|
continue
|
|
|
|
ruta = str(doc.archivo)
|
|
|
|
if not storage_service.file_exists(ruta):
|
|
missing_files.append(f"{doc.id} ({ruta})")
|
|
continue
|
|
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix='.tmp') as tmp:
|
|
tmp_path = tmp.name
|
|
temp_files.append(tmp_path)
|
|
|
|
success = storage_service.download_file(ruta, tmp_path)
|
|
|
|
if not success:
|
|
missing_files.append(f"{doc.id} ({ruta})")
|
|
continue
|
|
|
|
files_found.append(f"{doc.id} ({ruta})")
|
|
|
|
nombre_base = ruta.rsplit('/', 1)[-1]
|
|
file_name = slugify(nombre_base.rsplit('.', 1)[0])
|
|
ext = nombre_base.split('.')[-1] if '.' in nombre_base else ''
|
|
name_inside_zip = f"{file_name}.{ext}" if ext else file_name
|
|
|
|
with open(tmp_path, 'rb') as f:
|
|
zip_file.writestr(name_inside_zip, f.read())
|
|
|
|
buffer.seek(0)
|
|
zip_name = slugify(f"expediente_{pedimento.pedimento_app}")
|
|
response = HttpResponse(buffer, content_type='application/zip')
|
|
response['Content-Disposition'] = f'attachment; filename={zip_name or "documentos"}.zip'
|
|
|
|
if not files_found:
|
|
return Response(
|
|
{"error": f"No se encontraron documentos descargables para el pedimento: {pedimento.pedimento_app}"},
|
|
status=status.HTTP_404_NOT_FOUND
|
|
)
|
|
|
|
if missing_files:
|
|
response['X-Missing-Files-Count'] = str(len(missing_files))
|
|
response['Access-Control-Expose-Headers'] = 'X-Missing-Files-Count'
|
|
|
|
return response
|
|
|
|
except Exception as e:
|
|
return Response(
|
|
{"error": f"Error al crear el archivo ZIP: {str(e)}"},
|
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
|
)
|
|
finally:
|
|
for tmp_path in temp_files:
|
|
try:
|
|
if os.path.exists(tmp_path):
|
|
os.unlink(tmp_path)
|
|
except Exception as e:
|
|
logger.warning(f"No se pudo eliminar archivo temporal {tmp_path}: {e}")
|
|
|
|
class MultiPedimentoZipDownloadView(APIView):
|
|
permission_classes = [IsAuthenticated, require_permission('documentos.download')]
|
|
my_tags = ['Documents']
|
|
|
|
def post(self, request):
|
|
"""
|
|
Descarga todos los documentos de VARIOS pedimentos en un solo ZIP.
|
|
Body: { "pedimento_ids": ["uuid1", "uuid2", ...] }
|
|
"""
|
|
pedimento_ids = request.data.get('pedimento_ids', [])
|
|
if not isinstance(pedimento_ids, list) or not pedimento_ids:
|
|
return Response({"error": "Se requiere una lista de pedimento_ids"}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
# Filtrar pedimentos visibles para el usuario
|
|
base_qs = Pedimento.objects.filter(id__in=pedimento_ids)
|
|
if not request.user.is_superuser:
|
|
if not hasattr(request.user, 'organizacion'):
|
|
return Response({"error": "No autorizado"}, status=status.HTTP_403_FORBIDDEN)
|
|
base_qs = base_qs.filter(organizacion=request.user.organizacion)
|
|
|
|
pedimentos = base_qs.select_related('organizacion')
|
|
if not pedimentos.exists():
|
|
return Response({"error": "Ningún pedimento encontrado o autorizado"}, status=status.HTTP_404_NOT_FOUND)
|
|
|
|
# Obtener todos los documentos de esos pedimentos
|
|
docs = Document.objects.filter(pedimento__in=pedimentos)
|
|
if not docs.exists():
|
|
return Response({"error": "No hay documentos para estos pedimentos"}, status=status.HTTP_404_NOT_FOUND)
|
|
|
|
# Crear ZIP único
|
|
buffer = BytesIO()
|
|
missing_files = []
|
|
summary = {}
|
|
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
|
for doc in docs:
|
|
|
|
ped_key = doc.pedimento.pedimento_app
|
|
|
|
if not doc.archivo.name or not default_storage.exists(doc.archivo.name):
|
|
missing_files.append(f"{doc.id} ({doc.archivo.name or 'sin archivo'})")
|
|
logger.warning("Archivo faltante: %s", doc.id)
|
|
continue
|
|
|
|
summary[ped_key] = summary.get(ped_key, 0) + 1
|
|
|
|
# Nombre seguro: pedimento_app + nombre del archivo
|
|
file_name = slugify(doc.archivo.name.rsplit('/', 1)[-1].rsplit('.', 1)[0])
|
|
ext = doc.archivo.name.split('.')[-1]
|
|
name_inside_zip = f"{doc.pedimento.pedimento_app}/{file_name}.{ext}"
|
|
|
|
with doc.archivo.open('rb') as f:
|
|
zip_file.writestr(name_inside_zip, f.read())
|
|
|
|
buffer.seek(0)
|
|
zip_name = slugify(f"expedientes_{len(summary)}_pedimentos")
|
|
|
|
response = HttpResponse(buffer, content_type='application/zip')
|
|
response['Content-Disposition'] = f'attachment; filename={zip_name}.zip'
|
|
response['X-Zip-Filename'] = f"{zip_name}.zip"
|
|
response['Access-Control-Expose-Headers'] = 'X-Zip-Filename'
|
|
|
|
if missing_files:
|
|
response['X-Missing-Files'] = ', '.join(missing_files)
|
|
|
|
return response
|
|
|
|
class PedimentoDocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
|
|
"""
|
|
ViewSet for Document model.
|
|
"""
|
|
permission_classes = [IsAuthenticated, require_permission('documentos.view')]
|
|
model = Document
|
|
|
|
pagination_class = CustomPagination
|
|
serializer_class = DocumentSerializer
|
|
# Habilitar filtro por pedimento (UUID) y pedimento_numero (campo pedimento del modelo relacionado)
|
|
# filterset_fields = ['extension', 'size', 'document_type', 'pedimento', 'pedimento__pedimento']
|
|
filterset_fields = ['extension', 'size', 'pedimento', 'pedimento__pedimento','fuente']
|
|
|
|
# Puedes filtrar por pedimento usando: /api/record/documents/?pedimento=<id> o /api/record/documents/?pedimento__pedimento=<numero>
|
|
# Ejemplo: /api/record/documents/?pedimento_numero=12345678
|
|
my_tags = ['Documents']
|
|
|
|
def get_queryset(self):
|
|
if not user_has_permission(self.request.user, 'documentos.view'):
|
|
return Document.objects.none()
|
|
queryset = self.get_queryset_filtrado_por_organizacion()
|
|
pedimento_id = self.request.query_params.get('pedimento')
|
|
|
|
# Validar que el pedimento existe
|
|
from api.customs.models import Pedimento
|
|
try:
|
|
pedimento = Pedimento.objects.get(id=pedimento_id)
|
|
except Pedimento.DoesNotExist:
|
|
return Document.objects.none() # Retornar queryset vacío
|
|
|
|
# Filtrar SOLO por pedimento
|
|
queryset = queryset.filter(pedimento_id=pedimento_id)
|
|
|
|
# Tipos de documento permitidos (fijos: 2 y 3)
|
|
TIPOS_PERMITIDOS = ['2', '3']
|
|
tipo_documento = self.request.query_params.get('document_type')
|
|
|
|
if tipo_documento:
|
|
# Si se especifica tipo, filtrar por ese tipo (si está en permitidos)
|
|
if tipo_documento in TIPOS_PERMITIDOS:
|
|
queryset = queryset.filter(document_type_id=tipo_documento)
|
|
else:
|
|
# Si no se especifica, filtrar por los tipos permitidos
|
|
queryset = queryset.filter(document_type_id__in=TIPOS_PERMITIDOS)
|
|
|
|
# Filtros adicionales
|
|
buscar_archivo = self.request.query_params.get('archivo__icontains')
|
|
if buscar_archivo:
|
|
queryset = queryset.filter(archivo__icontains=buscar_archivo)
|
|
|
|
created_at__date = self.request.query_params.get('created_at__date')
|
|
if created_at__date:
|
|
queryset = queryset.filter(created_at__date=created_at__date)
|
|
|
|
pedimento_numero = self.request.query_params.get('pedimento_numero')
|
|
if pedimento_numero:
|
|
queryset = queryset.filter(pedimento__pedimento_app=pedimento_numero)
|
|
|
|
return queryset
|
|
|
|
class TriggerPedimentoCompletoView(APIView):
|
|
"""
|
|
Endpoint interno para disparar la descarga de pedimento completo
|
|
en el microservicio FastAPI. Reenvía el payload tal cual y devuelve
|
|
la respuesta del microservicio (normalmente un `task_id`).
|
|
"""
|
|
permission_classes = [IsAuthenticated, require_permission('pedimentos.process')]
|
|
|
|
my_tags = ['Microservice - Pedimento Completo']
|
|
|
|
def post(self, request):
|
|
|
|
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
|
|
return Response({"error": "Usuario no autenticado o sin organización"}, status=401)
|
|
|
|
# Validación mínima
|
|
# if not payload.get('credencial') or not payload.get('pedimento_id'):
|
|
# return Response({"error": "Se requieren 'credencial' y 'pedimento'"}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
if not request.data.get('pedimento_id'):
|
|
return Response({"error": "Se requieren 'credencial' y 'pedimento'"}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
pedimento_id = request.data.get('pedimento_id')
|
|
# Verificar que el pedimento existe y pertenece a la organización del usuario
|
|
try:
|
|
pedimento = Pedimento.objects.get(id=pedimento_id)
|
|
|
|
if not pedimento.contribuyente:
|
|
return Response({"error": "El pedimento no tiene un contribuyente asociado"}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
contribuyente_rfc = pedimento.contribuyente.rfc
|
|
|
|
payload = {
|
|
"pedimento": {
|
|
"id": str(pedimento.id),
|
|
"pedimento": pedimento.pedimento,
|
|
"pedimento_app": pedimento.pedimento_app,
|
|
"aduana": pedimento.aduana,
|
|
"patente": pedimento.patente,
|
|
"contribuyente": contribuyente_rfc,
|
|
"organizacion": str(pedimento.organizacion.id)
|
|
},
|
|
"credencial": {
|
|
"id": "",
|
|
"user": "",
|
|
"password": "",
|
|
"efirma": "",
|
|
"key": "",
|
|
"cer": "",
|
|
"is_active": False,
|
|
"organizacion": ""
|
|
}
|
|
}
|
|
except Pedimento.DoesNotExist:
|
|
return Response({"error": "Pedimento no encontrado"}, status=status.HTTP_404_NOT_FOUND)
|
|
except Exception as e:
|
|
return Response({"error": f"Error al buscar pedimento: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
|
|
try:
|
|
credenciales = CredencialesImportador.objects.get(rfc=contribuyente_rfc)
|
|
vucem = credenciales.vucem
|
|
|
|
# Obtener las rutas de los archivos, no los objetos FieldFile
|
|
key_path = vucem.key.path if vucem.key else ""
|
|
cer_path = vucem.cer.path if vucem.cer else ""
|
|
|
|
payload['credencial'] = {
|
|
"id": str(credenciales.id),
|
|
"user": vucem.usuario if vucem.usuario else "",
|
|
"password": vucem.password if vucem.password else "",
|
|
"efirma": vucem.efirma if vucem.efirma else "",
|
|
"key": key_path,
|
|
"cer": cer_path,
|
|
"is_active": vucem.is_active if vucem.is_active else False,
|
|
"organizacion": str(credenciales.organizacion.id) if credenciales.organizacion else ""
|
|
}
|
|
except CredencialesImportador.DoesNotExist:
|
|
return Response({"error": "No se encontró credencial VUCEM para la organización del pedimento"}, status=status.HTTP_404_NOT_FOUND)
|
|
except Exception as e:
|
|
return Response({"error": f"Error al buscar credencial VUCEM: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
|
|
try:
|
|
# Obtener la URL desde las variables de entorno
|
|
api_url = os.getenv('SERVICE_API_URL_V2')
|
|
logger.info(f"Usando MICROSERVICE_BASE_URL: {api_url}")
|
|
endpoint = f"{api_url.rstrip('/')}/services/auditar_pedimento_completo"
|
|
# endpoint = "http://localhost:8001/api/v2/services/auditar_pedimento_completo"
|
|
except requests.exceptions.RequestException as e:
|
|
logger.error(f"Error obteniendo MICROSERVICE_BASE_URL: {e}")
|
|
return Response({"error": "Error obteniendo la URL del microservicio", "detail": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
except Exception as e:
|
|
logger.error(f"Error inesperado obteniendo MICROSERVICE_BASE_URL: {e}")
|
|
return Response({"error": "Error inesperado obteniendo la URL del microservicio", "detail": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
|
|
try:
|
|
resp = requests.post(endpoint, json=payload, timeout=30)
|
|
except requests.exceptions.RequestException as e:
|
|
logger.error(f"Error comunicándose con microservice: {e}")
|
|
return Response({"error": "No se pudo conectar con el microservicio", "detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
|
|
|
|
try:
|
|
content = resp.json()
|
|
except ValueError:
|
|
content = {"detail": resp.text}
|
|
|
|
return Response(content, status=resp.status_code)
|
|
|
|
|
|
|
|
|