- Agregar endpoint bulk-delete para pedimentos en customs/views.py - Agregar endpoint bulk-delete para documentos en record/views.py - Incluir validaciones de seguridad y filtros por organización - Manejar gestión automática de almacenamiento en documentos - Agregar respuestas detalladas con conteo y errores
415 lines
18 KiB
Python
415 lines
18 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)
|
|
|
|
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) |