Files
backend/api/record/views.py
Jair Cedillo fa0d49a6d5 feat: Mejorar endpoints de carga masiva de documentos
 Nuevas funcionalidades:
- Corregir nomenclatura en bulk-create de pedimentos usando nombres exactos de archivos
- Endpoint bulk-upload para cargar múltiples documentos a un pedimento existente
- Soporte completo para archivos RAR y ZIP con manejo robusto

🔧 Mejoras técnicas bulk-create:
- Subdirectorios usan nombre exacto del archivo sin extensión (ej: 24-01-3420-1234567/)
- Resolución del problema de validación de nomenclatura inválida
- Mensajes de error mejorados con archivo original específico
- Procesamiento optimizado de múltiples archivos ZIP/RAR simultáneos

🔧 Mejoras técnicas bulk-upload:
- Organización heredada del pedimento en lugar del usuario
- Validación de cuotas de almacenamiento por organización
- Manejo de errores por archivo individual
- Soporte para múltiples tipos de archivo

📦 Dependencias:
- Agregado rarfile==4.1 para soporte completo de archivos RAR

🚀 Endpoints listos para producción:
- POST /api/customs/pedimentos/bulk-create/ (crear pedimentos + documentos)
- POST /api/record/documents/bulk-upload/ (subir documentos a pedimento existente)
2025-10-14 14:05:19 -05:00

620 lines
26 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 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 core.permissions import (
IsSameOrganization,
IsSameOrganizationDeveloper,
IsSameOrganizationAndAdmin,
IsSuperUser
)
import logging
logger = logging.getLogger(__name__)
from mixins.filtrado_organizacion import DocumentosFiltradosMixin
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.
"""
permission_classes = [IsAuthenticated & (IsSuperUser | IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper )]
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']
# 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):
queryset = self.get_queryset_filtrado_por_organizacion()
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)
# 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
serializer.save(size=new_file.size)
uso.espacio_utilizado = nuevo_espacio_utilizado
uso.save()
else:
serializer.save()
def perform_destroy(self, instance):
# 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=['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
deleted_count = existing_documents.count()
existing_documents.delete()
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 = [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 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-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 por defecto siempre
document_type, created = DocumentType.objects.get_or_create(
nombre="Documento General",
defaults={'descripcion': "Documento general sin tipo específico"}
)
if created:
print(f"✅ DocumentType creado: {document_type.nombre} (ID: {document_type.id})")
else:
print(f"♻️ DocumentType existente: {document_type.nombre} (ID: {document_type.id})")
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:
# Validaciones por archivo
if not file.name:
failed_files.append("archivo_sin_nombre")
errors.append("Archivo sin nombre detectado")
continue
# Obtener extensión del archivo
extension = file.name.split('.')[-1].lower() if '.' in file.name else ''
# Crear el documento
document = Document.objects.create(
organizacion=organizacion,
pedimento_id=pedimento_id,
document_type=document_type,
archivo=file,
size=file.size,
extension=extension
)
# 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)
class ProtectedDocumentDownloadView(APIView, DocumentosFiltradosMixin):
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
serializer_class = DocumentSerializer
model = Document
my_tags = ['Documents']
def get_queryset(self):
return self.get_queryset_filtrado_por_organizacion()
def get(self, request, pk):
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
raise Http404("Usuario no autenticado")
try:
doc = Document.objects.get(pk=pk)
except Document.DoesNotExist:
raise Http404("Documento no encontrado")
# Verifica que el usuario pertenece a la organización del documento
if self.request.user.is_superuser:
return FileResponse(doc.archivo.open('rb'))
if doc.organizacion != request.user.organizacion:
raise Http404("No autorizado")
return FileResponse(doc.archivo.open('rb'))
class BulkDownloadZipView(APIView):
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
my_tags = ['Documents']
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)
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()
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
for doc in docs:
# Usar solo el nombre del archivo sin descripcion
file_name = slugify(doc.archivo.name.rsplit('/', 1)[-1].rsplit('.', 1)[0])
ext = doc.archivo.name.split('.')[-1]
zip_name = f"{file_name}.{ext}"
doc.archivo.open('rb')
zip_file.writestr(zip_name, doc.archivo.read())
doc.archivo.close()
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'
return response
class GetFuenteView(APIView):
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
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 & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
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)