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": "" } """ 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= o /api/record/documents/?pedimento__pedimento= # 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)