feature/implementacion de gestor de informacion y archivos minIO

This commit is contained in:
Dulce
2026-04-22 11:10:05 -06:00
parent 69d07f2713
commit 39504e196c
23 changed files with 2272 additions and 391 deletions

View File

@@ -24,6 +24,7 @@ from rest_framework.decorators import action
from datetime import timedelta
from django.utils import timezone
from django.db.models import Q
from api.utils.storage_service import storage_service
from core.permissions import (
IsSameOrganization,
@@ -156,11 +157,10 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
def get_queryset(self):
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'])
queryset = queryset.exclude(document_type_id__in=['1','2','3','4','5','6','7','8','9','10','25','23','21','19','17','15','13','16'])
# Filtro personalizado por document_type
# document_type = self.request.query_params.get('document_type')
# if document_type:
@@ -252,14 +252,31 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
try:
# Guardar documento y actualizar espacio atómicamente
documento = serializer.save(
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()
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(
@@ -300,17 +317,45 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
}, code=status.HTTP_400_BAD_REQUEST)
# Actualizar documento y espacio
serializer.save(size=new_file.size)
uso.espacio_utilizado = nuevo_espacio_utilizado
uso.save()
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')
@@ -508,11 +553,10 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
archivos_eliminados = 0
for doc in existing_documents:
try:
# Eliminar archivo físico
if doc.archivo and doc.archivo.storage.exists(doc.archivo.name):
doc.archivo.delete(save=False) # save=False para no intentar guardar el modelo
# Eliminar registro de la base de datos
if doc.archivo:
ruta = str(doc.archivo)
storage_service.delete_file(ruta)
doc.delete()
archivos_eliminados += 1
except Exception as e:
@@ -700,13 +744,13 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
pass
# Eliminar los documentos
archivos_eliminados = 0
for doc in existing_documents:
archivos_eliminados = 0
try:
# Eliminar archivo físico
if doc.archivo and doc.archivo.storage.exists(doc.archivo.name):
doc.archivo.delete(save=False) # save=False para no intentar guardar el modelo
if doc.archivo:
ruta = str(doc.archivo)
storage_service.delete_file(ruta)
# Eliminar registro de la base de datos
doc.delete()
archivos_eliminados += 1
@@ -899,13 +943,13 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
pass
# Eliminar los documentos
archivos_eliminados = 0
for doc in existing_documents:
archivos_eliminados = 0
try:
# Eliminar archivo físico
if doc.archivo and doc.archivo.storage.exists(doc.archivo.name):
doc.archivo.delete(save=False) # save=False para no intentar guardar el modelo
if doc.archivo:
ruta = str(doc.archivo)
storage_service.delete_file(ruta)
# Eliminar registro de la base de datos
doc.delete()
archivos_eliminados += 1
@@ -1099,13 +1143,11 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
# Eliminar los documentos
archivos_eliminados = 0
for doc in existing_documents:
try:
# Eliminar archivo físico
if doc.archivo and doc.archivo.storage.exists(doc.archivo.name):
doc.archivo.delete(save=False) # save=False para no intentar guardar el modelo
# Eliminar registro de la base de datos
if doc.archivo:
ruta = str(doc.archivo)
storage_service.delete_file(ruta)
doc.delete()
archivos_eliminados += 1
except Exception as e:
@@ -1298,10 +1340,23 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
organizacion=organizacion,
pedimento_id=pedimento_id,
document_type=document_type,
archivo=file,
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
@@ -1586,11 +1641,23 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
organizacion=organizacion,
pedimento_id=pedimento_id,
document_type=document_type,
archivo=file,
size=file.size,
fuente_id=7,
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
@@ -1645,7 +1712,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
return Response(response_data, status=response_status)
class ProtectedDocumentDownloadView(APIView, DocumentosFiltradosMixin):
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
serializer_class = DocumentSerializer
model = Document
my_tags = ['Documents']
@@ -1654,6 +1721,10 @@ class ProtectedDocumentDownloadView(APIView, DocumentosFiltradosMixin):
return self.get_queryset_filtrado_por_organizacion()
def get(self, request, pk):
import tempfile
import os
from api.utils.storage_service import storage_service
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
raise Http404("Usuario no autenticado")
@@ -1662,21 +1733,39 @@ class ProtectedDocumentDownloadView(APIView, DocumentosFiltradosMixin):
except Document.DoesNotExist:
raise Http404("Documento no encontrado")
# Verifica que el usuario pertenece a la organización del documento
if not request.user.is_superuser:
if doc.organizacion != request.user.organizacion:
raise Http404("No autorizado")
if not doc.archivo:
raise Http404("Documento sin archivo asociado")
ruta = str(doc.archivo)
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'))
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 & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
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)
@@ -1695,22 +1784,87 @@ class BulkDownloadZipView(APIView):
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()
missing_files = []
temp_files = [] # Para limpiar después
files_found = []
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
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 & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
@@ -1745,7 +1899,7 @@ class DocumentTypeView(APIView):
return Response(serializer.data, status=200)
class ExpedienteZipDownloadView(APIView, DocumentosFiltradosMixin):
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
my_tags = ['Documents']
def post(self, request):
@@ -1753,6 +1907,10 @@ class ExpedienteZipDownloadView(APIView, DocumentosFiltradosMixin):
Descarga todos los documentos de un pedimento (o filtrados) en un ZIP.
Body: { "pedimento_id": "<uuid>" }
"""
import tempfile
import os
from api.utils.storage_service import storage_service
pedimento_id = request.data.get('pedimento_id')
if not pedimento_id:
return Response({"error": "Falta pedimento_id"}, status=status.HTTP_400_BAD_REQUEST)
@@ -1774,49 +1932,73 @@ class ExpedienteZipDownloadView(APIView, DocumentosFiltradosMixin):
if not docs.exists():
return Response({"error": "No hay documentos para este pedimento"}, status=status.HTTP_404_NOT_FOUND)
# 1. Crear un único buffer y ZIP para todos los archivos
buffer = BytesIO()
missing_files = [] # opcional: para informar después
files_found = []
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
for doc in docs:
# 2. Validaciones
if not doc.archivo.name:
logger.warning("Documento %s no tiene archivo asociado", doc.id)
missing_files.append(f"{doc.id} (sin archivo)")
continue
if not default_storage.exists(doc.archivo.name):
logger.warning("Archivo no encontrado en disco: %s", doc.archivo.path)
missing_files.append(f"{doc.id} ({doc.archivo.name})")
continue
files_found.append(f"{doc.id} ({doc.archivo.name})")
# 3. Nombre seguro para dentro del ZIP
file_name = slugify(doc.archivo.name.rsplit('/', 1)[-1].rsplit('.', 1)[0])
ext = doc.archivo.name.split('.')[-1]
name_inside_zip = f"{file_name}.{ext}"
# 4. Escribir el archivo dentro del ZIP
with doc.archivo.open('rb') as f:
zip_file.writestr(name_inside_zip, f.read())
# 5. Preparar respuesta
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 hay documentos para este pedimento: {pedimento.pedimento_app}"}, status=status.HTTP_404_NOT_FOUND)
# (Opcional) cabecera personalizada si faltaron archivos
# if missing_files:
# response['X-Missing-Files'] = ', '.join(missing_files)
# return Response({"error": f"No hay documentos para este pedimento: {pedimento.pedimento_app}"}, status=status.HTTP_404_NOT_FOUND)
return response
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 & (IsSuperUser | IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper)]
@@ -1905,39 +2087,37 @@ class PedimentoDocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
queryset = self.get_queryset_filtrado_por_organizacion()
pedimento_id = self.request.query_params.get('pedimento')
# Obtener el pedimento primero para usar su organización
# Validar que el pedimento existe
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
)
return Document.objects.none() # Retornar queryset vacío
# Tipos de documento permitidos (fijos en código, Pedimento completo y remesas)
TIPOS_PERMITIDOS = ['2', '3'] # <-- Ajusta aquí tus tipos
# 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:
queryset = queryset.filter(document_type_id__in=TIPOS_PERMITIDOS)
# 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:
# Filtrar por tipos permitidos
# queryset = queryset.filter(document_type_id__in=TIPOS_PERMITIDOS)
queryset = queryset.filter(
Q(archivo__istartswith=f'documents/vu_PC_')
# Q(archivo__startswith=f'documents/vu_RM_')
)
# 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=created_at__date)
queryset = queryset.filter(created_at__date=created_at__date)
# Filtro adicional por pedimento_numero si se proporciona
pedimento_numero = self.request.query_params.get('pedimento_numero')
if pedimento_numero:
queryset = queryset.filter(pedimento__pedimento_app=pedimento_numero)