Merge pull request 'feature/implementacion de gestor de informacion y archivos minIO' (#27) from feature/minio-implementation into main

Reviewed-on: #27
This commit is contained in:
2026-04-22 18:02:58 +00:00
23 changed files with 2272 additions and 391 deletions

View File

@@ -12,13 +12,28 @@ import json
def credenciales_to_dict(credenciales): def credenciales_to_dict(credenciales):
if not credenciales: if not credenciales:
return {} return {}
key_value = None
if credenciales.key:
if hasattr(credenciales.key, 'url'):
key_value = credenciales.key.url
else:
key_value = str(credenciales.key)
cer_value = None
if credenciales.cer:
if hasattr(credenciales.cer, 'url'):
cer_value = credenciales.cer.url
else:
cer_value = str(credenciales.cer)
return { return {
"id": str(credenciales.id), "id": str(credenciales.id),
"user": credenciales.usuario, "user": credenciales.usuario,
"password": credenciales.password, "password": credenciales.password,
"efirma": credenciales.efirma, "efirma": credenciales.efirma,
"key": credenciales.key.url if credenciales.key else None, "key": key_value,
"cer": credenciales.cer.url if credenciales.cer else None, "cer": cer_value,
"is_active": credenciales.is_active, "is_active": credenciales.is_active,
"organizacion": str(credenciales.organizacion.id) if credenciales.organizacion else None, "organizacion": str(credenciales.organizacion.id) if credenciales.organizacion else None,
} }

View File

@@ -61,6 +61,7 @@ except ImportError:
# Importar tarea de procesamiento de pedimento (Celery) # Importar tarea de procesamiento de pedimento (Celery)
from api.customs.tasks.microservice import procesar_pedimento_completo_individual from api.customs.tasks.microservice import procesar_pedimento_completo_individual
from api.utils.storage_service import storage_service
def get_available_extractors(): def get_available_extractors():
""" """
@@ -395,31 +396,8 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
@action(detail=False, methods=['post'], url_path='bulk-delete') @action(detail=False, methods=['post'], url_path='bulk-delete')
def bulk_delete(self, request): def bulk_delete(self, request):
""" import traceback
Endpoint para eliminar múltiples pedimentos de manera masiva.
Payload esperado:
{
"ids": ["uuid1", "uuid2", "uuid3", ...]
}
Respuesta exitosa:
{
"message": "Pedimentos eliminados exitosamente",
"deleted_count": 3,
"deleted_ids": ["uuid1", "uuid2", "uuid3"]
}
Respuesta con errores:
{
"message": "Algunos pedimentos no pudieron ser eliminados",
"deleted_count": 2,
"deleted_ids": ["uuid1", "uuid2"],
"failed_ids": ["uuid3"],
"errors": ["No se encontró el pedimento con ID uuid3"]
}
"""
# Obtener los IDs del payload
ids = request.data.get('ids', []) ids = request.data.get('ids', [])
if not ids: if not ids:
@@ -434,18 +412,11 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
# Obtener el queryset filtrado por organización
queryset = self.get_queryset() queryset = self.get_queryset()
# Filtrar solo los pedimentos que existen y pertenecen a la organización del usuario
existing_pedimentos = queryset.filter(id__in=ids) existing_pedimentos = queryset.filter(id__in=ids)
existing_ids = list(existing_pedimentos.values_list('id', flat=True)) existing_ids = list(existing_pedimentos.values_list('id', flat=True))
# Convertir UUIDs a strings para comparación
existing_ids_str = [str(id) for id in existing_ids] existing_ids_str = [str(id) for id in existing_ids]
requested_ids_str = [str(id) for id in 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] failed_ids = [id for id in requested_ids_str if id not in existing_ids_str]
deleted_count = 0 deleted_count = 0
@@ -453,20 +424,28 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
if existing_pedimentos.exists(): if existing_pedimentos.exists():
try: try:
# Eliminar los pedimentos encontrados for pedimento in existing_pedimentos:
documentos = Document.objects.filter(pedimento_id=pedimento.id)
for doc in documentos:
if doc.archivo:
ruta = str(doc.archivo)
try:
storage_service.delete_file(ruta)
except Exception as e:
traceback.print_exc()
documentos.delete()
deleted_count = existing_pedimentos.count() deleted_count = existing_pedimentos.count()
existing_pedimentos.delete() existing_pedimentos.delete()
except Exception as e: except Exception as e:
traceback.print_exc()
return Response( return Response(
{"error": f"Error al eliminar pedimentos: {str(e)}"}, {"error": f"Error al eliminar pedimentos: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR status=status.HTTP_500_INTERNAL_SERVER_ERROR
) )
# Agregar errores para IDs no encontrados
if failed_ids:
errors = [f"No se encontró el pedimento con ID {id} o no pertenece a su organización" for id in failed_ids]
# Preparar respuesta
response_data = { response_data = {
"deleted_count": deleted_count, "deleted_count": deleted_count,
"deleted_ids": existing_ids_str "deleted_ids": existing_ids_str
@@ -793,14 +772,14 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
print(f"Procesando documento: {file_name}") print(f"Procesando documento: {file_name}")
try: try:
# Leer el archivo desde el directorio temporal # Leer el archivo para extraer info del XML
with open(file_path, 'rb') as f: with open(file_path, 'rb') as f:
file_content = f.read() file_content = f.read()
from api.utils.helpers import extraer_info_pedimento_xml
# Extraer info del pedimento desde XML si es aplicable # Extraer info del pedimento desde XML si es aplicable
if file_name.lower().endswith('.xml'): if file_name.lower().endswith('.xml'):
try: try:
from api.utils.helpers import extraer_info_pedimento_xml
xml_info = extraer_info_pedimento_xml(file_content) xml_info = extraer_info_pedimento_xml(file_content)
if xml_info: if xml_info:
if 'numero_operacion' in xml_info: if 'numero_operacion' in xml_info:
@@ -815,8 +794,9 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
# Obtener información del archivo # Obtener información del archivo
extension = os.path.splitext(file_name)[1].lower().lstrip('.') extension = os.path.splitext(file_name)[1].lower().lstrip('.')
file_size = os.path.getsize(file_path)
# Buscar si ya existe un documento con el mismo nombre para este pedimento # Buscar si ya existe un documento con el mismo nombre
existing_documents = Document.objects.filter( existing_documents = Document.objects.filter(
pedimento_id=pedimento.id, pedimento_id=pedimento.id,
organizacion=organizacion organizacion=organizacion
@@ -829,25 +809,30 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
print(f"✅ Encontrado documento existente: ID {doc.id}") print(f"✅ Encontrado documento existente: ID {doc.id}")
break break
# Crear ContentFile
django_file = ContentFile(file_content, name=file_name)
if existing_document: if existing_document:
# Opcional: Eliminar el archivo físico anterior # Eliminar archivo anterior si existe
try: if existing_document.archivo:
if existing_document.archivo and os.path.exists(existing_document.archivo.path): storage_service.delete_file(existing_document.archivo)
os.remove(existing_document.archivo.path)
except (ValueError, OSError) as e:
print(f"No se pudo eliminar archivo físico anterior: {str(e)}")
# Actualizar el documento existente # Guardar nuevo archivo usando la ruta del archivo temporal
existing_document.archivo = django_file ruta = storage_service.save_document_from_path(
existing_document.size = len(file_content) file_path=file_path,
file_name=file_name,
organizacion_id=organizacion.id,
pedimento_app=pedimento_app,
metadata={
'pedimento_id': str(pedimento.id),
'document_id': str(existing_document.id),
'source': 'bulk_create_update'
}
)
if ruta:
existing_document.archivo = ruta
existing_document.size = file_size
existing_document.extension = extension existing_document.extension = extension
existing_document.updated_at = timezone.now() # Si tienes este campo
existing_document.save() existing_document.save()
documents_created += 1 documents_created += 1
print(f"📄 Documento actualizado: {file_name}")
else: else:
# Crear nuevo documento # Crear nuevo documento
@@ -856,16 +841,32 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
pedimento_id=pedimento.id, pedimento_id=pedimento.id,
document_type=document_type, document_type=document_type,
fuente_id=4, fuente_id=4,
archivo=django_file, size=file_size,
size=len(file_content),
extension=extension extension=extension
) )
# Guardar archivo usando la ruta del archivo temporal
ruta = storage_service.save_document_from_path(
file_path=file_path,
file_name=file_name,
organizacion_id=organizacion.id,
pedimento_app=pedimento_app,
metadata={
'pedimento_id': str(pedimento.id),
'document_id': str(document.id),
'source': 'bulk_create'
}
)
if ruta:
document.archivo = ruta
document.save()
documents_created += 1 documents_created += 1
print(f"📄 Nuevo documento creado: {file_name}") else:
document.delete()
except Exception as e: except Exception as e:
print(f"❌ Error al procesar documento {file_name}: {str(e)}") print(f"❌ Error al procesar documento {file_name}: {str(e)}")
# Continuar con otros documentos
print(f"🏁 Procesamiento completado. Archivos procesados en este directorio.") print(f"🏁 Procesamiento completado. Archivos procesados en este directorio.")
@@ -1359,8 +1360,7 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
# print(f"🔄 Iniciando creación de documento para pedimento ID: {pedimento.id}") # print(f"🔄 Iniciando creación de documento para pedimento ID: {pedimento.id}")
# Crear documento asociado al pedimento # Crear documento asociado al pedimento
try: try:
# print("📖 Leyendo archivo desde directorio temporal...") # Leer el archivo desde el directorio temporal (solo para XML/nomenclatura especial)
# Leer el archivo desde el directorio temporal
with open(file_path, 'rb') as f: with open(file_path, 'rb') as f:
file_content = f.read() file_content = f.read()
@@ -1372,20 +1372,14 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
# Patrón: 7 dígitos, punto, 3 dígitos (ej: M8988852.300) # Patrón: 7 dígitos, punto, 3 dígitos (ej: M8988852.300)
patron_nomenclatura = re.compile(r'^[m|M]\d{7}\.\d{3}$', re.IGNORECASE) patron_nomenclatura = re.compile(r'^[m|M]\d{7}\.\d{3}$', re.IGNORECASE)
# Separar nombre base y extensión
nombre_base, extension = os.path.splitext(file_name)
if patron_nomenclatura.match(file_name_lower): if patron_nomenclatura.match(file_name_lower):
tiene_nomenclatura_especial = True tiene_nomenclatura_especial = True
# Procesar el archivo con el método auxiliar # Procesar el archivo con el método auxiliar
info_extraida = procesar_archivo_m_con_nomenclatura(file_content, pedimento) info_extraida = procesar_archivo_m_con_nomenclatura(file_content, pedimento)
if info_extraida.get('tiene_nomenclatura_especial', False): if info_extraida.get('tiene_nomenclatura_especial', False):
# Agregar información de procesamiento a los datos de respuesta
if 'procesamiento_archivos' not in locals(): if 'procesamiento_archivos' not in locals():
procesamiento_archivos = [] procesamiento_archivos = []
procesamiento_archivos.append({ procesamiento_archivos.append({
'archivo': file_name, 'archivo': file_name,
'nomenclatura_especial': True, 'nomenclatura_especial': True,
@@ -1393,33 +1387,51 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
'actualizaciones': info_extraida.get('actualizaciones_aplicadas', []) 'actualizaciones': info_extraida.get('actualizaciones_aplicadas', [])
}) })
# print(f"📄 Archivo leído: {len(file_content)} bytes") extension = os.path.splitext(file_name)[1].lower().lstrip('.')
# Crear ContentFile que Django puede manejar correctamente file_size = os.path.getsize(file_path)
django_file = ContentFile(file_content, name=file_name)
fuente, created = Fuente.objects.get_or_create( fuente, created = Fuente.objects.get_or_create(
nombre="APP-EFC", nombre="APP-EFC",
descripcion='Transmitido por la app de escritorio' descripcion='Transmitido por la app de escritorio'
) )
# print(f"Creando documento para archivo: {file_name}")
# Crear documento - Django automáticamente guardará el archivo en media/documents/
document = Document.objects.create( document = Document.objects.create(
organizacion=organizacion, organizacion=organizacion,
pedimento_id=pedimento.id, pedimento_id=pedimento.id,
document_type=document_type, document_type=document_type,
fuente_id=fuente.id, fuente_id=fuente.id,
archivo=django_file, size=file_size,
size=len(file_content), extension=extension
extension=os.path.splitext(file_name)[1].lower().lstrip('.')
) )
# print(f"Documento creado exitosamente: {document.id}")
ruta = storage_service.save_document_from_path(
file_path=file_path,
file_name=file_name,
organizacion_id=organizacion.id,
pedimento_app=pedimento_app,
metadata={
'pedimento_id': str(pedimento.id),
'document_id': str(document.id),
'source': 'efc_app_desk',
'tiene_nomenclatura_especial': str(tiene_nomenclatura_especial)
}
)
if ruta:
document.archivo = ruta
document.save()
documents_created += 1 documents_created += 1
# print(f"📊 Total documentos creados hasta ahora: {documents_created}") else:
document.delete()
archivo_original = folder_name + ('.zip' if any(f.endswith('.zip') for f in [a.name for a in archivos]) else '.rar')
failed_files.append({
"file": relative_path,
"archivo_original": archivo_original,
"error": "Error al guardar archivo en storage"
})
continue
except Exception as e: except Exception as e:
# print(f"❌ Error al crear documento: {str(e)}")
archivo_original = folder_name + ('.zip' if any(f.endswith('.zip') for f in [a.name for a in archivos]) else '.rar') archivo_original = folder_name + ('.zip' if any(f.endswith('.zip') for f in [a.name for a in archivos]) else '.rar')
failed_files.append({ failed_files.append({
"file": relative_path, "file": relative_path,
@@ -1817,32 +1829,18 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
# Crear documento asociado al pedimento # Crear documento asociado al pedimento
try: try:
# Leer el archivo desde el directorio temporal extension = os.path.splitext(file_name)[1].lower().lstrip('.')
file_size = os.path.getsize(file_path)
file_name_lower = file_name.lower()
patron_nomenclatura = re.compile(r'^[m|M]\d{7}\.\d{3}$', re.IGNORECASE)
if patron_nomenclatura.match(file_name_lower):
with open(file_path, 'rb') as f: with open(file_path, 'rb') as f:
file_content = f.read() file_content = f.read()
# Verificar si el archivo tiene la nomenclatura especial M8988852.300
file_name_lower = file_name.lower()
tiene_nomenclatura_especial = False
info_extraida = {}
# Patrón: 7 dígitos, punto, 3 dígitos (ej: M8988852.300)
patron_nomenclatura = re.compile(r'^[m|M]\d{7}\.\d{3}$', re.IGNORECASE)
# Separar nombre base y extensión
nombre_base, extension = os.path.splitext(file_name)
if patron_nomenclatura.match(file_name_lower):
tiene_nomenclatura_especial = True
# Procesar el archivo con el método auxiliar
info_extraida = procesar_archivo_m_con_nomenclatura(file_content, pedimento) info_extraida = procesar_archivo_m_con_nomenclatura(file_content, pedimento)
if info_extraida.get('tiene_nomenclatura_especial', False): if info_extraida.get('tiene_nomenclatura_especial', False):
# Agregar información de procesamiento a los datos de respuesta
if 'procesamiento_archivos' not in locals(): if 'procesamiento_archivos' not in locals():
procesamiento_archivos = [] procesamiento_archivos = []
procesamiento_archivos.append({ procesamiento_archivos.append({
'archivo': file_name, 'archivo': file_name,
'nomenclatura_especial': True, 'nomenclatura_especial': True,
@@ -1850,20 +1848,15 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
'actualizaciones': info_extraida.get('actualizaciones_aplicadas', []) 'actualizaciones': info_extraida.get('actualizaciones_aplicadas', [])
}) })
# Crear ContentFile que Django puede manejar correctamente fuente, _ = Fuente.objects.get_or_create(
django_file = ContentFile(file_content, name=file_name)
fuente, created = Fuente.objects.get_or_create(
nombre="APP-EFC", nombre="APP-EFC",
descripcion='Transmitido por la app de escritorio' descripcion='Transmitido por la app de escritorio'
) )
# Buscar si ya existe un documento con el mismo nombre para este pedimento
existing_documents = Document.objects.filter( existing_documents = Document.objects.filter(
pedimento_id=pedimento.id, pedimento_id=pedimento.id,
organizacion=organizacion organizacion=organizacion
) )
existing_document = None existing_document = None
for doc in existing_documents: for doc in existing_documents:
if is_same_document(doc, file_name): if is_same_document(doc, file_name):
@@ -1871,37 +1864,49 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
break break
if existing_document: if existing_document:
# Opcional: Eliminar el archivo físico anterior if existing_document.archivo:
try: storage_service.delete_file(existing_document.archivo)
if existing_document.archivo and os.path.exists(existing_document.archivo.path):
os.remove(existing_document.archivo.path)
except (ValueError, OSError) as e:
pass
# Actualizar el documento existente con el nuevo archivo y datos ruta = storage_service.save_document_from_path(
existing_document.archivo = django_file file_path=file_path,
existing_document.size = len(file_content) file_name=file_name,
organizacion_id=organizacion.id,
pedimento_app=pedimento_app
)
if ruta:
existing_document.archivo = ruta
existing_document.size = file_size
existing_document.extension = extension existing_document.extension = extension
existing_document.updated_at = timezone.now() # Si tienes este campo
existing_document.save() existing_document.save()
documents_created += 1 documents_created += 1
else: else:
# Crear documento - Django automáticamente guardará el archivo en media/documents/
document = Document.objects.create( document = Document.objects.create(
organizacion=organizacion, organizacion=organizacion,
pedimento_id=pedimento.id, pedimento_id=pedimento.id,
document_type=document_type, document_type=document_type,
fuente_id=fuente.id, fuente_id=fuente.id,
archivo=django_file, size=file_size,
size=len(file_content), extension=extension
extension=os.path.splitext(file_name)[1].lower().lstrip('.')
) )
ruta = storage_service.save_document_from_path(
file_path=file_path,
file_name=file_name,
organizacion_id=organizacion.id,
pedimento_app=pedimento_app
)
if ruta:
document.archivo = ruta
document.save()
documents_created += 1 documents_created += 1
else:
document.delete()
raise Exception("Error al guardar archivo")
except Exception as e: except Exception as e:
archivo_original = folder_name + ('.zip' if any(f.endswith('.zip') for f in [a.name for a in archivos]) else '.rar') archivo_original = folder_name + '.zip'
failed_records.append({ failed_records.append({
"file": relative_path, "file": relative_path,
"archivo_original": archivo_original, "archivo_original": archivo_original,

View File

@@ -26,6 +26,49 @@ from api.organization.models import Organizacion
from api.record.models import Document from api.record.models import Document
from .tasks.auditoria import auditar_pedimento_por_id from .tasks.auditoria import auditar_pedimento_por_id
from .tasks.auditoria_xml import extraer_info_pedimento_xml from .tasks.auditoria_xml import extraer_info_pedimento_xml
import tempfile
import os
from api.utils.storage_service import storage_service
def get_document_content(documento):
"""
Obtiene el contenido de un documento (MinIO o local).
Retorna el contenido como string o bytes.
"""
ruta = str(documento.archivo)
with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp_path = tmp.name
try:
success = storage_service.download_file(ruta, tmp_path)
if not success:
return None
with open(tmp_path, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
return content
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
def get_document_path(documento):
"""
Obtiene la ruta temporal de un documento para lectura.
Retorna la ruta del archivo temporal descargado.
"""
ruta = str(documento.archivo)
tmp = tempfile.NamedTemporaryFile(delete=False)
tmp_path = tmp.name
tmp.close()
success = storage_service.download_file(ruta, tmp_path)
if not success:
return None
return tmp_path
@swagger_auto_schema( @swagger_auto_schema(
method='post', method='post',
@@ -729,10 +772,10 @@ def auditar_peticion_respuesta_pedimento_completo(request):
for documento in documentos_peticion: for documento in documentos_peticion:
nombre_archivo = os.path.basename(documento.archivo.name) nombre_archivo = os.path.basename(documento.archivo.name)
ruta_temporal = get_document_path(documento)
documentos_lista_peticiones.append({ documentos_lista_peticiones.append({
'id': str(documento.id), 'id': str(documento.id),
'archivo': documento.archivo.path, 'archivo': ruta_temporal,
'archivo_original': nombre_archivo, 'archivo_original': nombre_archivo,
'extension': documento.extension, 'extension': documento.extension,
'size': documento.size, 'size': documento.size,
@@ -1623,18 +1666,17 @@ def auditar_pedimento_endpoint(request):
try: try:
xml_info = { xml_info = {
'documento_id': str(documento.id), 'documento_id': str(documento.id),
'nombre_archivo': os.path.basename(documento.archivo.name), 'nombre_archivo': os.path.basename(str(documento.archivo)),
'tamanio': documento.size, 'tamanio': documento.size,
'extension': documento.extension, 'extension': documento.extension,
'tipo_documento': documento.document_type.descripcion if documento.document_type else 'Desconocido' 'tipo_documento': documento.document_type.descripcion if documento.document_type else 'Desconocido'
} }
# Intentar extraer información del XML xml_content = get_document_content(documento)
try:
with open(documento.archivo.path, 'r', encoding='utf-8') as xml_file:
xml_content = xml_file.read()
# Extraer información específica del XML if xml_content is None:
xml_info['error_lectura'] = 'No se pudo descargar el archivo'
else:
info_pedimento = extraer_info_pedimento_xml(xml_content) info_pedimento = extraer_info_pedimento_xml(xml_content)
if info_pedimento: if info_pedimento:
@@ -1644,15 +1686,12 @@ def auditar_pedimento_endpoint(request):
# Actualizar el pedimento con la información encontrada si es necesario # Actualizar el pedimento con la información encontrada si es necesario
actualizar_info_pedimento(pedimento, info_pedimento) actualizar_info_pedimento(pedimento, info_pedimento)
except Exception as e:
xml_info['error_lectura'] = str(e)
xmls_analizados.append(xml_info) xmls_analizados.append(xml_info)
except Exception as e: except Exception as e:
xmls_analizados.append({ xmls_analizados.append({
'documento_id': str(documento.id), 'documento_id': str(documento.id),
'nombre_archivo': os.path.basename(documento.archivo.name), 'nombre_archivo': os.path.basename(str(documento.archivo)),
'error': f'Error procesando archivo: {str(e)}' 'error': f'Error procesando archivo: {str(e)}'
}) })

View File

@@ -3,7 +3,8 @@ from django.db import models
# Create your models here. # Create your models here.
class DataStage(models.Model): class DataStage(models.Model):
organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='datastages', null=True, blank=True) organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='datastages', null=True, blank=True)
archivo = models.FileField(upload_to='datastages/', blank=False, null=False) # archivo = models.FileField(upload_to='datastages/', blank=False, null=False)
archivo = models.CharField(max_length=500, blank=True, null=True)
contribuyente = models.CharField(max_length=100, blank=False, null=False) contribuyente = models.CharField(max_length=100, blank=False, null=False)
procesado = models.BooleanField(default=False) procesado = models.BooleanField(default=False)

View File

@@ -1,12 +1,86 @@
from api.utils.storage_service import storage_service
from rest_framework import serializers from rest_framework import serializers
from .models import DataStage from .models import DataStage
from api.organization.models import Organizacion from api.organization.models import Organizacion
class DataStageSerializer(serializers.ModelSerializer): class DataStageSerializer(serializers.ModelSerializer):
archivo = serializers.FileField(write_only=True, required=False, allow_null=True)
download_url = serializers.SerializerMethodField(read_only=True)
organizacion = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=Organizacion.objects.all()) organizacion = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=Organizacion.objects.all())
class Meta: class Meta:
model = DataStage model = DataStage
fields = '__all__' fields = '__all__'
read_only_fields = ('id', 'created_at', 'updated_at') read_only_fields = ('id', 'created_at', 'updated_at')
# extra_kwargs = {'archivo': {'read_only': True},}
def get_download_url(self, obj):
"""Retorna URL de descarga según dónde esté el archivo"""
if not obj.archivo:
return None
if storage_service.is_minio_path(obj.archivo):
return storage_service.get_file_url(obj.archivo)
else:
request = self.context.get('request')
if request:
return request.build_absolute_uri(
f"/api/v1/datastage/datastages/{obj.id}/download-datastage/"
)
return f"/api/v1/datastage/datastages/{obj.id}/download-datastage/"
def create(self, validated_data):
"""Override para manejar la subida del archivo a MinIO"""
archivo_file = validated_data.pop('archivo', None)
organizacion = validated_data.get('organizacion')
datastage = super().create(validated_data)
print(f"ENDPOINT DE CREATE >>>>")
# guardarlo en MinIO
if archivo_file:
ruta = storage_service.save_datastage(
file=archivo_file,
organizacion_id=organizacion.id if organizacion else datastage.organizacion.id,
metadata={
'datastage_id': str(datastage.id),
'nombre': datastage.nombre if hasattr(datastage, 'nombre') else ''
}
)
if ruta:
datastage.archivo = ruta
datastage.save()
else:
# eliminar el registro creado
datastage.delete()
raise serializers.ValidationError({"archivo": "Error al guardar el archivo en el almacenamiento"})
return datastage
def update(self, instance, validated_data):
"""Override para manejar actualización de archivo"""
archivo_file = validated_data.pop('archivo', None)
organizacion = validated_data.get('organizacion', instance.organizacion)
instance = super().update(instance, validated_data)
# Si hay nuevo archivo, reemplazarlo
if archivo_file:
if instance.archivo:
storage_service.delete_file(instance.archivo)
ruta = storage_service.save_datastage(
file=archivo_file,
organizacion_id=organizacion.id,
metadata={
'datastage_id': str(instance.id),
'updated': 'true'
}
)
if ruta:
instance.archivo = ruta
instance.save()
else:
raise serializers.ValidationError({"archivo": "Error al guardar el nuevo archivo"})
return instance

View File

@@ -1,3 +1,4 @@
import tempfile
from celery import group from celery import group
from celery import shared_task from celery import shared_task
import logging import logging
@@ -6,81 +7,130 @@ from django.utils import timezone
import os import os
import zipfile import zipfile
import re import re
from api.utils.storage_service import storage_service
@shared_task @shared_task
def procesar_datastage_task(datastage_id, user_organizacion_id=None): def procesar_datastage_task(datastage_id, user_organizacion_id=None):
import traceback import traceback
tmp_path = None
try: try:
logger = logging.getLogger(__name__)
from api.datastage.models import DataStage from api.datastage.models import DataStage
from api.organization.models import Organizacion from api.organization.models import Organizacion
from api.customs.models import Pedimento, TipoOperacion, Regimen
# Obtener datastage
try:
datastage = DataStage.objects.get(id=datastage_id) datastage = DataStage.objects.get(id=datastage_id)
except DataStage.DoesNotExist:
return {'error': f'DataStage {datastage_id} no encontrado'}
# Validar archivo
if not datastage.archivo: if not datastage.archivo:
print("DataStage no tiene archivo asociado")
return {'detail': 'No hay archivo asociado a este DataStage.'} return {'detail': 'No hay archivo asociado a este DataStage.'}
file_path = datastage.archivo.path
if not os.path.exists(file_path): ruta_archivo = str(datastage.archivo)
return {'detail': 'El archivo no existe en el servidor.'}
if not file_path.endswith('.zip'): if not ruta_archivo.lower().endswith('.zip'):
return {'detail': 'El archivo no es un .zip.'} return {'detail': 'El archivo no es un .zip.'}
documentos_encontrados = [] # Descargar archivo
registros_cargados = {} with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as tmp:
registros_por_archivo = {} tmp_path = tmp.name
errores_por_archivo = {}
errores_pedimento = [] success = storage_service.download_file(ruta_archivo, tmp_path)
if not success:
print(f"No se pudo descargar: {ruta_archivo}")
return {'detail': f'No se pudo descargar el archivo: {ruta_archivo}'}
file_path = tmp_path
# Obtener organización
user_organizacion = None user_organizacion = None
if user_organizacion_id: if user_organizacion_id:
try:
user_organizacion = Organizacion.objects.get(id=user_organizacion_id) user_organizacion = Organizacion.objects.get(id=user_organizacion_id)
except Organizacion.DoesNotExist:
print(f"Organización no encontrada: {user_organizacion_id}")
def to_snake_case(name): # Leer ZIP y lanzar subtareas
s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
s2 = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1)
return s2.replace('__', '_').lower()
# Lanzar una subtarea por cada archivo ASC
subtasks = [] subtasks = []
with zipfile.ZipFile(file_path, 'r') as zip_ref: with zipfile.ZipFile(file_path, 'r') as zip_ref:
for asc_name in zip_ref.namelist(): namelist = zip_ref.namelist()
for asc_name in namelist:
if asc_name.endswith('.asc'): if asc_name.endswith('.asc'):
subtasks.append(procesar_archivo_asc_task.s(datastage_id, user_organizacion_id, asc_name)) subtasks.append(
procesar_archivo_asc_task.s(datastage_id, user_organizacion_id, asc_name)
)
if subtasks: if subtasks:
job = group(subtasks).apply_async() job = group(subtasks).apply_async()
print(f"Grupo de tareas lanzado: {job.id}")
return { return {
'group_id': job.id, 'group_id': job.id,
'subtask_ids': [t.id for t in job.results], 'subtask_ids': [t.id for t in job.results],
'detail': 'Procesamiento lanzado. Monitorea el estado de cada subtask_id.' 'detail': f'Procesamiento lanzado. {len(subtasks)} archivos .ASC en cola.'
} }
print("No se encontraron archivos .ASC")
return {'detail': 'No se encontraron archivos .asc'} return {'detail': 'No se encontraron archivos .asc'}
except Exception as e: except Exception as e:
import traceback import traceback
return {'error': str(e), 'traceback': traceback.format_exc()} return {'error': str(e), 'traceback': traceback.format_exc()}
finally:
# Limpiar temporal
if tmp_path and os.path.exists(tmp_path):
try:
os.unlink(tmp_path)
except Exception as e:
print(f"No se pudo eliminar temporal: {e}")
@shared_task @shared_task
def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name): def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
import traceback """
Procesa un archivo .ASC individual dentro del ZIP
"""
tmp_path = None
try: try:
logger = logging.getLogger(__name__)
from api.datastage.models import DataStage from api.datastage.models import DataStage
from api.organization.models import Organizacion from api.organization.models import Organizacion
from api.customs.models import Pedimento, TipoOperacion, Regimen from api.customs.models import Pedimento, TipoOperacion, Regimen
from django.apps import apps import datetime
import zipfile
import re # Obtener datastage
datastage = DataStage.objects.get(id=datastage_id) datastage = DataStage.objects.get(id=datastage_id)
user_organizacion = None user_organizacion = None
if user_organizacion_id: if user_organizacion_id:
user_organizacion = Organizacion.objects.get(id=user_organizacion_id) user_organizacion = Organizacion.objects.get(id=user_organizacion_id)
file_path = datastage.archivo.path
ruta_archivo = str(datastage.archivo)
# Descargar archivo
with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as tmp:
tmp_path = tmp.name
success = storage_service.download_file(ruta_archivo, tmp_path)
if not success:
return {'errores': [f'No se pudo descargar el archivo: {ruta_archivo}']}
file_path = tmp_path
def to_snake_case(name): def to_snake_case(name):
s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name) s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
s2 = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1) s2 = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1)
return s2.replace('__', '_').lower() return s2.replace('__', '_').lower()
objects_to_create = []
with zipfile.ZipFile(file_path, 'r') as zip_ref: with zipfile.ZipFile(file_path, 'r') as zip_ref:
if asc_name not in zip_ref.namelist(): if asc_name not in zip_ref.namelist():
print(f"{asc_name} no encontrado en el ZIP")
return {'errores': [f'{asc_name} no encontrado en el zip']} return {'errores': [f'{asc_name} no encontrado en el zip']}
# Determinar modelo
match = re.match(r'.*_(\d+)\.asc$', asc_name) match = re.match(r'.*_(\d+)\.asc$', asc_name)
if match: if match:
registro_key = match.group(1) registro_key = match.group(1)
@@ -96,53 +146,53 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
Model = apps.get_model('datastage', model_name) Model = apps.get_model('datastage', model_name)
except LookupError: except LookupError:
return {'errores': [f"No existe el modelo para {model_name}"]} return {'errores': [f"No existe el modelo para {model_name}"]}
# Procesar archivo
with zip_ref.open(asc_name) as asc_file: with zip_ref.open(asc_name) as asc_file:
first = True first = True
field_names = []
field_names_snake = [] field_names_snake = []
objects_to_create = [] line_count = 0
errores_pedimento = []
for line in asc_file: for line in asc_file:
line_decoded = None line_count += 1
try: try:
line_decoded = line.decode('utf-8').strip() line_decoded = line.decode('utf-8').strip()
except UnicodeDecodeError: except UnicodeDecodeError:
try: try:
line_decoded = line.decode('latin-1').strip() line_decoded = line.decode('latin-1').strip()
except Exception as e: except Exception:
continue
except Exception as e:
continue continue
if not line_decoded: if not line_decoded:
continue continue
if first: if first:
field_names = [f for f in line_decoded.split('|')] field_names = [f for f in line_decoded.split('|')]
field_names_snake = [to_snake_case(f) for f in field_names] field_names_snake = [to_snake_case(f) for f in field_names]
first = False first = False
continue continue
values = line_decoded.split('|') values = line_decoded.split('|')
while values and values[-1] == '': while values and values[-1] == '':
values.pop() values.pop()
if len(values) == len(field_names_snake) + 1 and values[-1] == '':
values = values[:-1]
if len(values) < len(field_names_snake):
values += [None] * (len(field_names_snake) - len(values))
if len(values) != len(field_names_snake): if len(values) != len(field_names_snake):
continue continue
data = dict(zip(field_names_snake, values)) data = dict(zip(field_names_snake, values))
if hasattr(Model, 'organizacion_id'): if hasattr(Model, 'organizacion_id'):
data['organizacion_id'] = user_organizacion.id if user_organizacion else None data['organizacion_id'] = user_organizacion.id if user_organizacion else None
if hasattr(Model, 'datastage_id'): if hasattr(Model, 'datastage_id'):
data['datastage_id'] = datastage.id data['datastage_id'] = datastage.id
# Limpiar campos de fecha vacíos ('') a None
# Limpiar fechas vacías
for field in Model._meta.get_fields(): for field in Model._meta.get_fields():
if hasattr(field, 'get_internal_type') and field.get_internal_type() in ["DateField", "DateTimeField"]: if hasattr(field, 'get_internal_type') and field.get_internal_type() in ["DateField", "DateTimeField"]:
if data.get(field.name) == "": if data.get(field.name) == "":
data[field.name] = None data[field.name] = None
# Convertir fecha_pago_real a timezone-aware si existe
# Convertir fecha_pago_real
if 'fecha_pago_real' in data and data['fecha_pago_real']: if 'fecha_pago_real' in data and data['fecha_pago_real']:
from django.utils import timezone
import datetime
fecha_val = data['fecha_pago_real'] fecha_val = data['fecha_pago_real']
if isinstance(fecha_val, str): if isinstance(fecha_val, str):
try: try:
@@ -156,11 +206,11 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
dt = timezone.make_aware(dt) dt = timezone.make_aware(dt)
if dt: if dt:
data['fecha_pago_real'] = dt data['fecha_pago_real'] = dt
elif isinstance(fecha_val, datetime.datetime) and timezone.is_naive(fecha_val):
data['fecha_pago_real'] = timezone.make_aware(fecha_val)
try: try:
obj = Model(**data) obj = Model(**data)
objects_to_create.append(obj) objects_to_create.append(obj)
# Si es Registro501, crear Pedimento # Si es Registro501, crear Pedimento
if model_name == 'Registro501': if model_name == 'Registro501':
organizacion_instance = None organizacion_instance = None
@@ -169,7 +219,7 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
try: try:
organizacion_instance = Organizacion.objects.get(id=org_id) organizacion_instance = Organizacion.objects.get(id=org_id)
except Exception as org_exc: except Exception as org_exc:
logger.warning(f"No se encontró la organización con id {org_id}: {org_exc}") print(f"No se encontró la organización con id {org_id}: {org_exc}")
if not organizacion_instance: if not organizacion_instance:
organizacion_instance = user_organizacion organizacion_instance = user_organizacion
fecha_pago_raw = data.get('fecha_pago_real') fecha_pago_raw = data.get('fecha_pago_real')
@@ -198,7 +248,7 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
pedimento_app = f"{year[-2:]}-{str(aduana).zfill(2)[:2]}-{str(patente).zfill(4)[-4:]}-{str(pedimento_num).zfill(7)[-7:]}" pedimento_app = f"{year[-2:]}-{str(aduana).zfill(2)[:2]}-{str(patente).zfill(4)[-4:]}-{str(pedimento_num).zfill(7)[-7:]}"
# logger.info(f"pedimento_app >>>> {pedimento_app}") # logger.info(f"pedimento_app >>>> {pedimento_app}")
except Exception as ped_app_exc: except Exception as ped_app_exc:
logger.warning(f"No se pudo generar pedimento_app: {ped_app_exc}") print(f"No se pudo generar pedimento_app: {ped_app_exc}")
tipo_operacion_val = data.get('tipo_operacion') tipo_operacion_val = data.get('tipo_operacion')
tipo_operacion = TipoOperacion.objects.filter(id=int(tipo_operacion_val)).first() if tipo_operacion_val else None tipo_operacion = TipoOperacion.objects.filter(id=int(tipo_operacion_val)).first() if tipo_operacion_val else None
regimen = Regimen.objects.filter(claveped=data.get('clave_documento', '').strip(), tipo=tipo_operacion.id if tipo_operacion else None).first() if tipo_operacion else None regimen = Regimen.objects.filter(claveped=data.get('clave_documento', '').strip(), tipo=tipo_operacion.id if tipo_operacion else None).first() if tipo_operacion else None
@@ -237,11 +287,14 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
pass pass
except Exception as e: except Exception as e:
continue continue
# Bulk create
if objects_to_create: if objects_to_create:
try: try:
Model.objects.bulk_create(objects_to_create, batch_size=1000) Model.objects.bulk_create(objects_to_create, batch_size=1000)
except Exception as e: except Exception as e:
return {'archivo': asc_name, 'error': str(e), 'traceback': traceback.format_exc()} return {'archivo': asc_name, 'error': str(e)}
return { return {
'archivo': asc_name, 'archivo': asc_name,
'insertados': len(objects_to_create) 'insertados': len(objects_to_create)
@@ -250,32 +303,10 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
import traceback import traceback
return {'archivo': asc_name, 'error': str(e), 'traceback': traceback.format_exc()} return {'archivo': asc_name, 'error': str(e), 'traceback': traceback.format_exc()}
detalles = {} finally:
for key in ['502', '503', '504']: # Limpiar temporal
model_name = f'Registro{key}' if tmp_path and os.path.exists(tmp_path):
asc_file = None
encabezado = None
errores = []
for asc_name in registros_por_archivo:
if asc_name.endswith(f'_{key}.asc'):
asc_file = asc_name
break
if asc_file:
try: try:
with zipfile.ZipFile(file_path, 'r') as zip_ref: os.unlink(tmp_path)
with zip_ref.open(asc_file) as f:
for line in f:
try:
encabezado = line.decode('utf-8').strip()
except UnicodeDecodeError:
encabezado = line.decode('latin-1').strip()
break
except Exception as e: except Exception as e:
encabezado = f'Error leyendo encabezado: {e}' print(f"No se pudo eliminar temporal: {e}")
errores = errores_por_archivo.get(asc_file, [])
detalles[model_name] = {
'archivo': asc_file,
'encabezado': encabezado,
'errores': errores
}
return {'registros_cargados': registros_cargados, 'errores_pedimento': errores_pedimento}

View File

@@ -1,3 +1,8 @@
import atexit
import tempfile
from api.utils.storage_service import storage_service
from config import settings
from rest_framework.pagination import PageNumberPagination from rest_framework.pagination import PageNumberPagination
from api.customs.models import Pedimento, TipoOperacion, Regimen from api.customs.models import Pedimento, TipoOperacion, Regimen
from django.shortcuts import render from django.shortcuts import render
@@ -112,19 +117,66 @@ class DataStageViewSet(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
def download_datastage(self, request, pk=None): def download_datastage(self, request, pk=None):
""" """
Endpoint para descargar el archivo asociado a un DataStage. Endpoint para descargar el archivo asociado a un DataStage.
Soporta tanto archivos en MinIO como archivos locales antiguos.
""" """
try: try:
datastage = self.get_object() datastage = self.get_object()
if not datastage.archivo: if not datastage.archivo:
raise Http404("No hay archivo asociado a este DataStage.") raise Http404("No hay archivo asociado a este DataStage.")
file_path = datastage.archivo.path
if not os.path.exists(file_path): # Detectar si es ruta de MinIO o local
raise Http404("El archivo no existe en el servidor.") is_minio_path = datastage.archivo.startswith('org_')
response = FileResponse(open(file_path, 'rb'), as_attachment=True, filename=os.path.basename(file_path))
if is_minio_path:
import tempfile
with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp_path = tmp.name
success = storage_service.download_file(datastage.archivo, tmp_path)
if not success:
raise Http404("No se pudo descargar el archivo de MinIO")
filename = os.path.basename(datastage.archivo)
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 return response
else:
file_path = os.path.join(settings.MEDIA_ROOT, str(datastage.archivo))
if not os.path.exists(file_path):
raise Http404(f"El archivo no existe: {file_path}")
filename = os.path.basename(file_path)
response = FileResponse(
open(file_path, 'rb'),
as_attachment=True,
filename=filename
)
return response
except Exception as e: except Exception as e:
return Response({'detail': str(e)}, status=404) return Response({'detail': str(e)}, status=404)
def perform_destroy(self, instance):
"""
Al eliminar un DataStage, también eliminar su archivo asociado.
"""
if instance.archivo:
storage_service.delete_file(instance.archivo)
instance.delete()
@action(detail=True, methods=['post'], url_path='procesar') @action(detail=True, methods=['post'], url_path='procesar')
def procesar(self, request, pk=None): def procesar(self, request, pk=None):
""" """

View File

View File

View File

@@ -0,0 +1,472 @@
import os
import time
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
from django.core.management.base import BaseCommand
from django.conf import settings
from minio import Minio
from api.record.models import Document
from api.datastage.models import DataStage
from api.vucem.models import Vucem
from api.reports.models import ReportDocument
class Command(BaseCommand):
help = 'Migra archivos existentes del sistema local a MinIO (versión optimizada)'
def add_arguments(self, parser):
parser.add_argument('--dry-run', action='store_true', help='Solo muestra lo que se migraría')
parser.add_argument('--model', type=str, help='Document, DataStage, Vucem, ReportDocument')
parser.add_argument('--limit', type=int, help='Límite de registros')
parser.add_argument('--batch-size', type=int, default=200, help='Tamaño del lote (default: 200)')
parser.add_argument('--workers', type=int, default=3, help='Número de workers (default: 3)')
parser.add_argument('--offset', type=int, default=0, help='Offset inicial (para reanudar)')
def __init__(self):
super().__init__()
self.client = None
self.bucket_name = None
def _init_minio_client(self):
"""Inicializa el cliente MinIO"""
if self.client is None:
self.client = Minio(
endpoint=os.getenv('MINIO_ENDPOINT', 'minio:9000'),
access_key=os.getenv('MINIO_ACCESS_KEY'),
secret_key=os.getenv('MINIO_SECRET_KEY'),
secure=os.getenv('MINIO_SECURE', 'false').lower() == 'true'
)
self.bucket_name = os.getenv('MINIO_BUCKET_NAME', 'efc-backend-dev')
def handle(self, *args, **options):
dry_run = options.get('dry_run', False)
model_filter = options.get('model')
limit = options.get('limit')
batch_size = options.get('batch_size', 200)
workers = options.get('workers', 3)
offset = options.get('offset', 0)
self.stdout.write(self.style.WARNING('=' * 60))
self.stdout.write(self.style.WARNING('INICIANDO MIGRACIÓN A MINIO (OPTIMIZADA)'))
self.stdout.write(self.style.WARNING(f'Batch size: {batch_size} | Workers: {workers} | Offset: {offset}'))
if dry_run:
self.stdout.write(self.style.WARNING('MODO: DRY RUN (sin cambios)'))
self.stdout.write(self.style.WARNING('=' * 60))
results = {}
if not model_filter or model_filter.lower() == 'document':
results['Document'] = self.migrate_documents(dry_run, limit, batch_size, workers, offset)
if not model_filter or model_filter.lower() == 'datastage':
results['DataStage'] = self.migrate_datastage(dry_run, limit, batch_size, workers, offset)
if not model_filter or model_filter.lower() == 'vucem':
results['Vucem'] = self.migrate_vucem(dry_run, limit, workers)
if not model_filter or model_filter.lower() == 'reportdocument':
results['ReportDocument'] = self.migrate_reports(dry_run, limit, batch_size, workers, offset)
# Resumen final
self.stdout.write('\n' + '=' * 60)
self.stdout.write(self.style.SUCCESS('RESUMEN DE MIGRACIÓN'))
self.stdout.write('=' * 60)
total_migrados = 0
total_no_encontrados = 0
total_errores = 0
for model_name, stats in results.items():
self.stdout.write(f"\n📁 {model_name}:")
self.stdout.write(f" ✅ Migrados: {stats['migrated']}")
self.stdout.write(f" ⚠️ No encontrados: {stats['not_found']}")
self.stdout.write(f" ❌ Errores: {stats['errors']}")
total_migrados += stats['migrated']
total_no_encontrados += stats['not_found']
total_errores += stats['errors']
self.stdout.write('\n' + '-' * 40)
self.stdout.write(f"📊 TOTAL Migrados: {total_migrados}")
self.stdout.write(f"📊 TOTAL No encontrados: {total_no_encontrados}")
self.stdout.write(f"📊 TOTAL Errores: {total_errores}")
if dry_run:
self.stdout.write('\n' + self.style.WARNING('⚠️ MODO DRY RUN - No se realizaron cambios'))
def get_local_file_path(self, path_str):
"""Obtiene la ruta completa del archivo local"""
return Path(settings.MEDIA_ROOT) / path_str
def migrate_documents(self, dry_run, limit, batch_size, workers, offset):
"""Migra documentos del modelo Document"""
self._init_minio_client()
stats = {'migrated': 0, 'not_found': 0, 'errors': 0}
queryset = Document.objects.exclude(archivo='').exclude(archivo__isnull=True)
queryset = queryset.exclude(archivo__startswith='org_')
queryset = queryset.order_by('created_at')
if offset:
queryset = queryset[offset:]
if limit:
queryset = queryset[:limit]
total = queryset.count()
self.stdout.write(f"\n📄 Procesando {total} documentos...")
if total == 0:
return stats
start_time = time.time()
processed = 0
# Procesar en lotes
for batch_start in range(0, total, batch_size):
batch = queryset[batch_start:batch_start + batch_size]
batch_docs = list(batch)
if dry_run:
stats['migrated'] += len(batch_docs)
processed += len(batch_docs)
self._print_progress(processed, total, start_time, stats)
continue
# Preparar items para workers
items = []
for doc in batch_docs:
path_str = str(doc.archivo)
local_path = self.get_local_file_path(path_str)
if not local_path.exists():
stats['not_found'] += 1
continue
pedimento_app = doc.pedimento.pedimento_app if doc.pedimento else 'unknown'
items.append({
'doc': doc,
'local_path': local_path,
'path_str': path_str,
'pedimento_app': pedimento_app
})
# Procesar en paralelo
if items:
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = {executor.submit(self._upload_document, item): item for item in items}
for future in as_completed(futures):
result = future.result()
if result['success']:
stats['migrated'] += 1
else:
stats['errors'] += 1
processed += len(batch_docs)
self._print_progress(processed, total, start_time, stats)
total_time = time.time() - start_time
self.stdout.write(f"\n ✅ Completado en {total_time/60:.1f} minutos")
return stats
def _upload_document(self, item):
"""Sube un documento directamente a MinIO"""
try:
doc = item['doc']
local_path = item['local_path']
pedimento_app = item['pedimento_app']
filename = local_path.name
# Generar ruta MinIO
object_name = f"org_{doc.organizacion_id}/documents/{pedimento_app}/{filename}"
# Subir directamente a MinIO
self.client.fput_object(
bucket_name=self.bucket_name,
object_name=object_name,
file_path=str(local_path)
)
# Actualizar base de datos
doc.archivo = object_name
doc.save(update_fields=['archivo'])
return {'success': True, 'doc_id': doc.id}
except Exception as e:
return {'success': False, 'doc_id': doc.id, 'error': str(e)}
def migrate_datastage(self, dry_run, limit, batch_size, workers, offset):
"""Migra archivos del modelo DataStage"""
self._init_minio_client()
stats = {'migrated': 0, 'not_found': 0, 'errors': 0}
queryset = DataStage.objects.exclude(archivo='').exclude(archivo__isnull=True)
queryset = queryset.exclude(archivo__startswith='org_')
queryset = queryset.order_by('created_at')
if offset:
queryset = queryset[offset:]
if limit:
queryset = queryset[:limit]
total = queryset.count()
self.stdout.write(f"\n📦 Procesando {total} archivos DataStage...")
if total == 0:
return stats
start_time = time.time()
processed = 0
for batch_start in range(0, total, batch_size):
batch = queryset[batch_start:batch_start + batch_size]
batch_docs = list(batch)
if dry_run:
stats['migrated'] += len(batch_docs)
processed += len(batch_docs)
self._print_progress(processed, total, start_time, stats)
continue
items = []
for ds in batch_docs:
path_str = str(ds.archivo)
local_path = self.get_local_file_path(path_str)
if not local_path.exists():
stats['not_found'] += 1
continue
items.append({'ds': ds, 'local_path': local_path})
if items:
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = {executor.submit(self._upload_datastage, item): item for item in items}
for future in as_completed(futures):
result = future.result()
if result['success']:
stats['migrated'] += 1
else:
stats['errors'] += 1
processed += len(batch_docs)
self._print_progress(processed, total, start_time, stats)
total_time = time.time() - start_time
self.stdout.write(f"\n ✅ Completado en {total_time/60:.1f} minutos")
return stats
def _upload_datastage(self, item):
"""Sube un DataStage directamente a MinIO"""
try:
ds = item['ds']
local_path = item['local_path']
filename = local_path.name
object_name = f"org_{ds.organizacion_id}/datastage/{filename}"
self.client.fput_object(
bucket_name=self.bucket_name,
object_name=object_name,
file_path=str(local_path)
)
ds.archivo = object_name
ds.save(update_fields=['archivo'])
return {'success': True, 'id': ds.id}
except Exception as e:
return {'success': False, 'id': ds.id, 'error': str(e)}
def migrate_vucem(self, dry_run, limit, workers):
"""Migra archivos key y cer del modelo Vucem"""
self._init_minio_client()
stats = {'migrated': 0, 'not_found': 0, 'errors': 0}
queryset = Vucem.objects.all()
if limit:
queryset = queryset[:limit]
total = queryset.count() * 2
self.stdout.write(f"\n🔐 Procesando {queryset.count()} registros VUCEM (key + cer)...")
if total == 0:
return stats
items = []
for vucem in queryset:
if vucem.key and not str(vucem.key).startswith('org_'):
path_str = str(vucem.key)
local_path = self.get_local_file_path(path_str)
if local_path.exists():
items.append({'vucem': vucem, 'local_path': local_path, 'tipo': 'key'})
else:
stats['not_found'] += 1
if vucem.cer and not str(vucem.cer).startswith('org_'):
path_str = str(vucem.cer)
local_path = self.get_local_file_path(path_str)
if local_path.exists():
items.append({'vucem': vucem, 'local_path': local_path, 'tipo': 'cer'})
else:
stats['not_found'] += 1
if dry_run:
stats['migrated'] = len(items)
self.stdout.write(f" 📝 [DRY RUN] Se migrarían {len(items)} archivos")
return stats
if items:
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = {executor.submit(self._upload_vucem, item): item for item in items}
for future in as_completed(futures):
result = future.result()
if result['success']:
stats['migrated'] += 1
self.stdout.write(self.style.SUCCESS(f"{result['tipo']} migrado: {result['id']}"))
else:
stats['errors'] += 1
return stats
def _upload_vucem(self, item):
"""Sube un archivo VUCEM directamente a MinIO"""
try:
vucem = item['vucem']
local_path = item['local_path']
tipo = item['tipo']
filename = local_path.name
if tipo == 'key':
object_name = f"org_{vucem.organizacion_id}/vucem_keys/{filename}"
vucem.key = object_name
vucem.save(update_fields=['key'])
else:
object_name = f"org_{vucem.organizacion_id}/vucem_certs/{filename}"
vucem.cer = object_name
vucem.save(update_fields=['cer'])
self.client.fput_object(
bucket_name=self.bucket_name,
object_name=object_name,
file_path=str(local_path)
)
return {'success': True, 'id': vucem.id, 'tipo': tipo}
except Exception as e:
return {'success': False, 'id': vucem.id, 'tipo': tipo, 'error': str(e)}
def migrate_reports(self, dry_run, limit, batch_size, workers, offset):
"""Migra archivos del modelo ReportDocument"""
self._init_minio_client()
stats = {'migrated': 0, 'not_found': 0, 'errors': 0}
queryset = ReportDocument.objects.exclude(file='').exclude(file__isnull=True)
queryset = queryset.exclude(file__startswith='org_')
queryset = queryset.order_by('created_at')
if offset:
queryset = queryset[offset:]
if limit:
queryset = queryset[:limit]
total = queryset.count()
self.stdout.write(f"\n📊 Procesando {total} reportes...")
if total == 0:
return stats
start_time = time.time()
processed = 0
for batch_start in range(0, total, batch_size):
batch = queryset[batch_start:batch_start + batch_size]
batch_docs = list(batch)
if dry_run:
stats['migrated'] += len(batch_docs)
processed += len(batch_docs)
self._print_progress(processed, total, start_time, stats)
continue
items = []
for report in batch_docs:
path_str = str(report.file)
local_path = self.get_local_file_path(path_str)
if not local_path.exists():
stats['not_found'] += 1
continue
items.append({'report': report, 'local_path': local_path})
if items:
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = {executor.submit(self._upload_report, item): item for item in items}
for future in as_completed(futures):
result = future.result()
if result['success']:
stats['migrated'] += 1
else:
stats['errors'] += 1
processed += len(batch_docs)
self._print_progress(processed, total, start_time, stats)
total_time = time.time() - start_time
self.stdout.write(f"\n ✅ Completado en {total_time/60:.1f} minutos")
return stats
def _upload_report(self, item):
"""Sube un reporte directamente a MinIO"""
try:
report = item['report']
local_path = item['local_path']
filename = local_path.name
filters = report.filters or {}
org_id = filters.get('organizacion_id', 'unknown')
object_name = f"org_{org_id}/reports/{filename}"
self.client.fput_object(
bucket_name=self.bucket_name,
object_name=object_name,
file_path=str(local_path)
)
report.file = object_name
report.save(update_fields=['file'])
return {'success': True, 'id': report.id}
except Exception as e:
return {'success': False, 'id': report.id, 'error': str(e)}
def _print_progress(self, processed, total, start_time, stats):
"""Imprime el progreso actual"""
elapsed = time.time() - start_time
rate = processed / elapsed if elapsed > 0 else 0
pct = processed * 100 / total if total > 0 else 0
self.stdout.write(
f" 📊 {processed}/{total} ({pct:.1f}%) | "
f"{rate:.0f} docs/seg | "
f"{stats['migrated']} | "
f"⚠️ {stats['not_found']} | "
f"{stats['errors']}"
)

View File

@@ -17,6 +17,14 @@ class DocumentSerializer(serializers.ModelSerializer):
read_only_fields = ('id', 'size', 'extension', 'created_at', 'updated_at', 'pedimento_numero') read_only_fields = ('id', 'size', 'extension', 'created_at', 'updated_at', 'pedimento_numero')
def get_pedimento_numero(self, obj): def get_pedimento_numero(self, obj):
# Si es un diccionario (durante create)
if isinstance(obj, dict):
pedimento = obj.get('pedimento')
if pedimento and hasattr(pedimento, 'pedimento_app'):
return pedimento.pedimento_app
return None
# Si es una instancia del modelo (durante retrieve/list)
if obj.pedimento: if obj.pedimento:
return obj.pedimento.pedimento_app return obj.pedimento.pedimento_app
return None return None
@@ -28,9 +36,19 @@ class DocumentSerializer(serializers.ModelSerializer):
return value return value
def get_fuente_nombre(self, obj): def get_fuente_nombre(self, obj):
# Método 1: Si la fuente está precargada con select_related """Obtiene el nombre de la fuente de forma segura"""
if isinstance(obj, dict):
fuente = obj.get('fuente')
if fuente and hasattr(fuente, 'nombre'):
return fuente.nombre
return "Desconocido"
try:
if obj.fuente: if obj.fuente:
return obj.fuente.nombre return obj.fuente.nombre
except AttributeError:
pass
return "Desconocido" return "Desconocido"
class FuenteSerializer(serializers.ModelSerializer): class FuenteSerializer(serializers.ModelSerializer):

View File

@@ -24,6 +24,7 @@ from rest_framework.decorators import action
from datetime import timedelta from datetime import timedelta
from django.utils import timezone from django.utils import timezone
from django.db.models import Q from django.db.models import Q
from api.utils.storage_service import storage_service
from core.permissions import ( from core.permissions import (
IsSameOrganization, IsSameOrganization,
@@ -156,11 +157,10 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
def get_queryset(self): def get_queryset(self):
queryset = self.get_queryset_filtrado_por_organizacion() queryset = self.get_queryset_filtrado_por_organizacion()
modulo_efc = self.request.query_params.get('modulo') modulo_efc = self.request.query_params.get('modulo')
if modulo_efc: if modulo_efc:
if modulo_efc == 'expedientes-detalle-pedimentos': 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 # Filtro personalizado por document_type
# document_type = self.request.query_params.get('document_type') # document_type = self.request.query_params.get('document_type')
# if document_type: # if document_type:
@@ -252,14 +252,31 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
try: try:
# Guardar documento y actualizar espacio atómicamente pedimento = serializer.validated_data.get('pedimento')
documento = serializer.save( pedimento_app = pedimento.pedimento_app if pedimento else None
documento = Document.objects.create(
document_type=document_type, document_type=document_type,
organizacion=organizacion, organizacion=organizacion,
pedimento=pedimento,
size=archivo.size, size=archivo.size,
extension=archivo.name.split('.')[-1].lower() 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: except Exception as e:
# Guardar documento y actualizar espacio atómicamente # Guardar documento y actualizar espacio atómicamente
documento = serializer.save( documento = serializer.save(
@@ -300,17 +317,45 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
}, code=status.HTTP_400_BAD_REQUEST) }, code=status.HTTP_400_BAD_REQUEST)
# Actualizar documento y espacio # Actualizar documento y espacio
serializer.save(size=new_file.size) 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.espacio_utilizado = nuevo_espacio_utilizado
uso.save() uso.save()
else:
raise ValidationError({"archivo": "Error al actualizar el archivo"})
else: else:
serializer.save() serializer.save()
def perform_destroy(self, instance): 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 # Restar el espacio al eliminar
uso = UsoAlmacenamiento.objects.get(organizacion=instance.organizacion) uso = UsoAlmacenamiento.objects.get(organizacion=instance.organizacion)
uso.espacio_utilizado -= instance.size uso.espacio_utilizado -= instance.size
uso.save() uso.save()
instance.delete() instance.delete()
@action(detail=False, methods=['get'], url_path='vu-documentos-errores') @action(detail=False, methods=['get'], url_path='vu-documentos-errores')
@@ -508,11 +553,10 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
archivos_eliminados = 0 archivos_eliminados = 0
for doc in existing_documents: for doc in existing_documents:
try: try:
# Eliminar archivo físico if doc.archivo:
if doc.archivo and doc.archivo.storage.exists(doc.archivo.name): ruta = str(doc.archivo)
doc.archivo.delete(save=False) # save=False para no intentar guardar el modelo storage_service.delete_file(ruta)
# Eliminar registro de la base de datos
doc.delete() doc.delete()
archivos_eliminados += 1 archivos_eliminados += 1
except Exception as e: except Exception as e:
@@ -700,12 +744,12 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
pass pass
# Eliminar los documentos # Eliminar los documentos
for doc in existing_documents:
archivos_eliminados = 0 archivos_eliminados = 0
for doc in existing_documents:
try: try:
# Eliminar archivo físico if doc.archivo:
if doc.archivo and doc.archivo.storage.exists(doc.archivo.name): ruta = str(doc.archivo)
doc.archivo.delete(save=False) # save=False para no intentar guardar el modelo storage_service.delete_file(ruta)
# Eliminar registro de la base de datos # Eliminar registro de la base de datos
doc.delete() doc.delete()
@@ -899,12 +943,12 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
pass pass
# Eliminar los documentos # Eliminar los documentos
for doc in existing_documents:
archivos_eliminados = 0 archivos_eliminados = 0
for doc in existing_documents:
try: try:
# Eliminar archivo físico if doc.archivo:
if doc.archivo and doc.archivo.storage.exists(doc.archivo.name): ruta = str(doc.archivo)
doc.archivo.delete(save=False) # save=False para no intentar guardar el modelo storage_service.delete_file(ruta)
# Eliminar registro de la base de datos # Eliminar registro de la base de datos
doc.delete() doc.delete()
@@ -1099,13 +1143,11 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
# Eliminar los documentos # Eliminar los documentos
archivos_eliminados = 0 archivos_eliminados = 0
for doc in existing_documents: for doc in existing_documents:
try: try:
# Eliminar archivo físico if doc.archivo:
if doc.archivo and doc.archivo.storage.exists(doc.archivo.name): ruta = str(doc.archivo)
doc.archivo.delete(save=False) # save=False para no intentar guardar el modelo storage_service.delete_file(ruta)
# Eliminar registro de la base de datos
doc.delete() doc.delete()
archivos_eliminados += 1 archivos_eliminados += 1
except Exception as e: except Exception as e:
@@ -1298,11 +1340,24 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
organizacion=organizacion, organizacion=organizacion,
pedimento_id=pedimento_id, pedimento_id=pedimento_id,
document_type=document_type, document_type=document_type,
archivo=file,
size=file.size, size=file.size,
extension=extension 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 # Actualizar espacio usado
espacio_usado_temp += file.size espacio_usado_temp += file.size
total_space_used += file.size total_space_used += file.size
@@ -1586,12 +1641,24 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
organizacion=organizacion, organizacion=organizacion,
pedimento_id=pedimento_id, pedimento_id=pedimento_id,
document_type=document_type, document_type=document_type,
archivo=file,
size=file.size, size=file.size,
fuente_id=7,
extension=extension 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 # Actualizar espacio usado
espacio_usado_temp += file.size espacio_usado_temp += file.size
total_space_used += file.size total_space_used += file.size
@@ -1654,6 +1721,10 @@ class ProtectedDocumentDownloadView(APIView, DocumentosFiltradosMixin):
return self.get_queryset_filtrado_por_organizacion() return self.get_queryset_filtrado_por_organizacion()
def get(self, request, pk): 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'): if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
raise Http404("Usuario no autenticado") raise Http404("Usuario no autenticado")
@@ -1662,21 +1733,39 @@ class ProtectedDocumentDownloadView(APIView, DocumentosFiltradosMixin):
except Document.DoesNotExist: except Document.DoesNotExist:
raise Http404("Documento no encontrado") raise Http404("Documento no encontrado")
# Verifica que el usuario pertenece a la organización del documento if not request.user.is_superuser:
if self.request.user.is_superuser:
return FileResponse(doc.archivo.open('rb'))
if doc.organizacion != request.user.organizacion: if doc.organizacion != request.user.organizacion:
raise Http404("No autorizado") raise Http404("No autorizado")
return FileResponse(doc.archivo.open('rb')) 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): class BulkDownloadZipView(APIView):
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
my_tags = ['Documents'] my_tags = ['Documents']
def post(self, request): 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'): if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
return Response({"error": "Usuario no autenticado o sin organización"}, status=401) return Response({"error": "Usuario no autenticado o sin organización"}, status=401)
@@ -1695,23 +1784,88 @@ class BulkDownloadZipView(APIView):
return Response({"error": "Uno o más documentos no existen o no pertenecen a su organización."}, status=404) return Response({"error": "Uno o más documentos no existen o no pertenecen a su organización."}, status=404)
buffer = BytesIO() buffer = BytesIO()
missing_files = []
temp_files = [] # Para limpiar después
files_found = []
try:
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file: with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
for doc in docs: for doc in docs:
# Usar solo el nombre del archivo sin descripcion if not doc.archivo:
file_name = slugify(doc.archivo.name.rsplit('/', 1)[-1].rsplit('.', 1)[0]) missing_files.append(f"{doc.id} (sin archivo)")
ext = doc.archivo.name.split('.')[-1] continue
zip_name = f"{file_name}.{ext}"
doc.archivo.open('rb') ruta = str(doc.archivo)
zip_file.writestr(zip_name, doc.archivo.read())
doc.archivo.close() # ============ 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) buffer.seek(0)
safe_name = slugify(pedimento_nombre) safe_name = slugify(pedimento_nombre)
response = HttpResponse(buffer, content_type='application/zip') response = HttpResponse(buffer, content_type='application/zip')
response['Content-Disposition'] = f'attachment; filename={safe_name or "documentos"}.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 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): class GetFuenteView(APIView):
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
serializer_class = FuenteSerializer serializer_class = FuenteSerializer
@@ -1753,6 +1907,10 @@ class ExpedienteZipDownloadView(APIView, DocumentosFiltradosMixin):
Descarga todos los documentos de un pedimento (o filtrados) en un ZIP. Descarga todos los documentos de un pedimento (o filtrados) en un ZIP.
Body: { "pedimento_id": "<uuid>" } Body: { "pedimento_id": "<uuid>" }
""" """
import tempfile
import os
from api.utils.storage_service import storage_service
pedimento_id = request.data.get('pedimento_id') pedimento_id = request.data.get('pedimento_id')
if not pedimento_id: if not pedimento_id:
return Response({"error": "Falta pedimento_id"}, status=status.HTTP_400_BAD_REQUEST) return Response({"error": "Falta pedimento_id"}, status=status.HTTP_400_BAD_REQUEST)
@@ -1774,50 +1932,74 @@ class ExpedienteZipDownloadView(APIView, DocumentosFiltradosMixin):
if not docs.exists(): if not docs.exists():
return Response({"error": "No hay documentos para este pedimento"}, status=status.HTTP_404_NOT_FOUND) 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() buffer = BytesIO()
missing_files = [] # opcional: para informar después missing_files = []
files_found = [] files_found = []
temp_files = []
try:
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file: with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
for doc in docs: for doc in docs:
# 2. Validaciones if not doc.archivo:
if not doc.archivo.name:
logger.warning("Documento %s no tiene archivo asociado", doc.id)
missing_files.append(f"{doc.id} (sin archivo)") missing_files.append(f"{doc.id} (sin archivo)")
continue continue
if not default_storage.exists(doc.archivo.name):
logger.warning("Archivo no encontrado en disco: %s", doc.archivo.path) ruta = str(doc.archivo)
missing_files.append(f"{doc.id} ({doc.archivo.name})")
if not storage_service.file_exists(ruta):
missing_files.append(f"{doc.id} ({ruta})")
continue continue
files_found.append(f"{doc.id} ({doc.archivo.name})") with tempfile.NamedTemporaryFile(delete=False, suffix='.tmp') as tmp:
tmp_path = tmp.name
temp_files.append(tmp_path)
# 3. Nombre seguro para dentro del ZIP success = storage_service.download_file(ruta, tmp_path)
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 if not success:
with doc.archivo.open('rb') as f: 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()) zip_file.writestr(name_inside_zip, f.read())
# 5. Preparar respuesta
buffer.seek(0) buffer.seek(0)
zip_name = slugify(f"expediente_{pedimento.pedimento_app}") zip_name = slugify(f"expediente_{pedimento.pedimento_app}")
response = HttpResponse(buffer, content_type='application/zip') response = HttpResponse(buffer, content_type='application/zip')
response['Content-Disposition'] = f'attachment; filename={zip_name or "documentos"}.zip' response['Content-Disposition'] = f'attachment; filename={zip_name or "documentos"}.zip'
if not files_found: if not files_found:
return Response({"error": f"No hay documentos para este pedimento: {pedimento.pedimento_app}"}, status=status.HTTP_404_NOT_FOUND) return Response(
{"error": f"No se encontraron documentos descargables para el pedimento: {pedimento.pedimento_app}"},
status=status.HTTP_404_NOT_FOUND
)
# (Opcional) cabecera personalizada si faltaron archivos if missing_files:
# if missing_files: response['X-Missing-Files-Count'] = str(len(missing_files))
# response['X-Missing-Files'] = ', '.join(missing_files) response['Access-Control-Expose-Headers'] = 'X-Missing-Files-Count'
# return Response({"error": f"No hay documentos para este pedimento: {pedimento.pedimento_app}"}, status=status.HTTP_404_NOT_FOUND)
return response 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): class MultiPedimentoZipDownloadView(APIView):
permission_classes = [IsAuthenticated & (IsSuperUser | IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper)] permission_classes = [IsAuthenticated & (IsSuperUser | IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper)]
my_tags = ['Documents'] my_tags = ['Documents']
@@ -1905,39 +2087,37 @@ class PedimentoDocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
queryset = self.get_queryset_filtrado_por_organizacion() queryset = self.get_queryset_filtrado_por_organizacion()
pedimento_id = self.request.query_params.get('pedimento') 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 from api.customs.models import Pedimento
try: try:
pedimento = Pedimento.objects.get(id=pedimento_id) pedimento = Pedimento.objects.get(id=pedimento_id)
except Pedimento.DoesNotExist: except Pedimento.DoesNotExist:
return Response( return Document.objects.none() # Retornar queryset vacío
{"error": "Pedimento no encontrado"},
status=status.HTTP_404_NOT_FOUND
)
# Tipos de documento permitidos (fijos en código, Pedimento completo y remesas) # Filtrar SOLO por pedimento
TIPOS_PERMITIDOS = ['2', '3'] # <-- Ajusta aquí tus tipos 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') tipo_documento = self.request.query_params.get('document_type')
if tipo_documento: 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) queryset = queryset.filter(document_type_id__in=TIPOS_PERMITIDOS)
else: # Filtros adicionales
# 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_')
)
buscar_archivo = self.request.query_params.get('archivo__icontains') buscar_archivo = self.request.query_params.get('archivo__icontains')
if buscar_archivo: if buscar_archivo:
queryset = queryset.filter(archivo__icontains=buscar_archivo) queryset = queryset.filter(archivo__icontains=buscar_archivo)
created_at__date = self.request.query_params.get('created_at__date') created_at__date = self.request.query_params.get('created_at__date')
if 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') pedimento_numero = self.request.query_params.get('pedimento_numero')
if pedimento_numero: if pedimento_numero:
queryset = queryset.filter(pedimento__pedimento_app=pedimento_numero) queryset = queryset.filter(pedimento__pedimento_app=pedimento_numero)

View File

@@ -16,7 +16,8 @@ class ReportDocument(models.Model):
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, related_name='report_documents') user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, related_name='report_documents')
filters = models.JSONField(blank=True, null=True) filters = models.JSONField(blank=True, null=True)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
file = models.FileField(upload_to='reports/', blank=True, null=True) # file = models.FileField(upload_to='reports/', blank=True, null=True)
file = models.CharField(max_length=500, blank=True, null=True)
report_type = models.CharField(max_length=30, choices=TYPE_REPORT, default='cumplimiento') report_type = models.CharField(max_length=30, choices=TYPE_REPORT, default='cumplimiento')
error_message = models.TextField(blank=True, null=True) error_message = models.TextField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)

View File

@@ -1,3 +1,6 @@
import tempfile
from api.utils.storage_service import storage_service
from celery import shared_task from celery import shared_task
from api.organization.models import Organizacion from api.organization.models import Organizacion
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
@@ -10,6 +13,7 @@ from api.record.models import Document
import csv import csv
import os import os
from django.conf import settings from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile
@shared_task @shared_task
def generate_report_document(report_id): def generate_report_document(report_id):
@@ -46,15 +50,19 @@ def generate_report_document(report_id):
filename = f"{filename}.csv" if not filename.endswith('.csv') else filename filename = f"{filename}.csv" if not filename.endswith('.csv') else filename
else: else:
filename = f"report_{report.id}_{timezone.now().strftime('%Y%m%d%H%M%S')}.csv" filename = f"report_{report.id}_{timezone.now().strftime('%Y%m%d%H%M%S')}.csv"
file_path = os.path.join(settings.MEDIA_ROOT, 'reports', filename)
os.makedirs(os.path.dirname(file_path), exist_ok=True) with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.csv', encoding='utf-8', newline='') as f:
with open(file_path, 'w', newline='', encoding='utf-8') as f: tmp_path = f.name
# Escribir CSV en archivo temporal
with open(tmp_path, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f) writer = csv.writer(f)
headers = [ headers = [
'aduana', 'patente', 'regimen', 'pedimento', 'pedimento_app', 'clave_pedimento', 'aduana', 'patente', 'regimen', 'pedimento', 'pedimento_app', 'clave_pedimento',
'tipo_operacion_id', 'contribuyente_id', 'tipo_documento', 'numero_documento', 'estado', 'acuse_estado' 'tipo_operacion_id', 'contribuyente_id', 'tipo_documento', 'numero_documento', 'estado', 'acuse_estado'
] ]
writer.writerow(headers) writer.writerow(headers)
for ped in pedimentos: for ped in pedimentos:
for cove in Cove.objects.filter(pedimento=ped): for cove in Cove.objects.filter(pedimento=ped):
writer.writerow([ writer.writerow([
@@ -74,12 +82,43 @@ def generate_report_document(report_id):
ped.clave_pedimento, ped.tipo_operacion_id, ped.contribuyente_id, ped.clave_pedimento, ped.tipo_operacion_id, ped.contribuyente_id,
'PARTIDA', partida.numero_partida, partida.descargado, '' 'PARTIDA', partida.numero_partida, partida.descargado, ''
]) ])
# Guardar el archivo en el modelo
with open(file_path, 'rb') as f: # ============ NUEVO: Guardar en MinIO ============
report.file.save(filename, ContentFile(f.read()), save=True) # Leer archivo temporal
with open(tmp_path, 'rb') as f:
file_content = f.read()
# Crear UploadedFile
uploaded_file = SimpleUploadedFile(
name=filename,
content=file_content,
content_type='text/csv'
)
# Guardar en storage
ruta = storage_service.save_report(
file=uploaded_file,
organizacion_id=filters.get('organizacion_id'),
metadata={
'report_id': str(report.id),
'report_type': 'cumplimiento',
'user_id': str(report.user.id) if report.user else None
}
)
if ruta:
report.file = ruta
report.status = 'ready' report.status = 'ready'
else:
report.status = 'error'
report.error_message = 'Error al guardar el archivo en storage'
# Limpiar temporal
os.unlink(tmp_path)
report.finished_at = timezone.now() report.finished_at = timezone.now()
report.save(update_fields=['status', 'file', 'finished_at']) report.save(update_fields=['status', 'file', 'finished_at', 'error_message'])
except Exception as e: except Exception as e:
report.status = 'error' report.status = 'error'
report.error_message = str(e) report.error_message = str(e)

View File

@@ -1,9 +1,13 @@
from api.reports.models import ReportDocument from api.reports.models import ReportDocument
from api.reports.tasks.report_document import generate_report_document, generate_report_control_pedimento from api.reports.tasks.report_document import generate_report_document, generate_report_control_pedimento
from django.http import FileResponse from django.http import FileResponse
from api.utils.storage_service import storage_service
from rest_framework.decorators import api_view, permission_classes from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
import tempfile
import os
import atexit
@api_view(['GET']) @api_view(['GET'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
@@ -71,7 +75,9 @@ def table_summary(request):
"report_id": report.id, "report_id": report.id,
"status": report.status, "status": report.status,
"created_at": report.created_at, "created_at": report.created_at,
"download_url": report.file.url if report.file else None # "download_url": report.file.url if report.file else None
"download_url": storage_service.get_file_url(report.file) if report.file else None
}, status=202) }, status=202)
@api_view(['GET']) @api_view(['GET'])
@@ -85,7 +91,9 @@ def report_document_status(request, report_id):
"created_at": report.created_at, "created_at": report.created_at,
"finished_at": report.finished_at, "finished_at": report.finished_at,
"error_message": report.error_message, "error_message": report.error_message,
"download_url": report.file.url if report.file else None # "download_url": report.file.url if report.file else None
"download_url": storage_service.get_file_url(report.file) if report.file else None
} }
return Response(data) return Response(data)
except ReportDocument.DoesNotExist: except ReportDocument.DoesNotExist:
@@ -103,7 +111,8 @@ def report_document_list(request):
"created_at": r.created_at, "created_at": r.created_at,
"finished_at": r.finished_at, "finished_at": r.finished_at,
"error_message": r.error_message, "error_message": r.error_message,
"download_url": r.file.url if r.file else None # "download_url": r.file.url if r.file else None
"download_url": storage_service.get_file_url(r.file) if r.file else None
} }
for r in reports for r in reports
] ]
@@ -116,8 +125,22 @@ def report_document_download(request, report_id):
report = ReportDocument.objects.get(id=report_id, user=request.user) report = ReportDocument.objects.get(id=report_id, user=request.user)
if not report.file: if not report.file:
return Response({"error": "El archivo aún no está disponible"}, status=404) return Response({"error": "El archivo aún no está disponible"}, status=404)
response = FileResponse(report.file.open('rb'), as_attachment=True, filename=report.file.name)
ruta = str(report.file)
with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:
tmp_path = tmp.name
success = storage_service.download_file(ruta, tmp_path)
if not success:
return Response({"error": "No se pudo descargar el archivo"}, status=500)
filename = os.path.basename(ruta)
response = FileResponse(open(tmp_path, 'rb'),as_attachment=True,filename=filename)
atexit.register(lambda: os.unlink(tmp_path) if os.path.exists(tmp_path) else None)
return response return response
except ReportDocument.DoesNotExist: except ReportDocument.DoesNotExist:
return Response({"error": "Reporte no encontrado"}, status=404) return Response({"error": "Reporte no encontrado"}, status=404)

143
api/utils/minio_client.py Normal file
View File

@@ -0,0 +1,143 @@
# backend/utils/minio_client.py
from datetime import timedelta
import os
from minio import Minio
from minio.error import S3Error
from django.conf import settings
from typing import Optional, BinaryIO
import logging
logger = logging.getLogger(__name__)
class MinIOClient:
"""Cliente singleton para MinIO con operaciones avanzadas"""
_instance = None
_client = None
_bucket_name = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if self._client is None and settings.STORAGE_BACKEND == 'minio':
self._initialize_client()
def _initialize_client(self):
"""Inicializa el cliente de MinIO"""
try:
endpoint = os.getenv('MINIO_ENDPOINT', 'minio:9000')
access_key = os.getenv('MINIO_ACCESS_KEY')
secret_key = os.getenv('MINIO_SECRET_KEY')
secure = os.getenv('MINIO_SECURE', 'false').lower() == 'true'
self._client = Minio(
endpoint=endpoint,
access_key=access_key,
secret_key=secret_key,
secure=secure
)
self._bucket_name = os.environ.get('MINIO_BUCKET_NAME', 'efc-backend-dev')
# Asegurar que el bucket existe
if not self._client.bucket_exists(self._bucket_name):
self._client.make_bucket(self._bucket_name)
except Exception as e:
raise
def upload_file(
self,
object_name: str,
file_path: str = None,
file_data: BinaryIO = None,
content_type: str = None,
metadata: dict = None
) -> bool:
"""
Sube un archivo a MinIO
Args:
object_name: Ruta del objeto en el bucket (ej: 'documents/archivo.xml')
file_path: Ruta local del archivo (opcional)
file_data: Datos del archivo en memoria (opcional)
content_type: MIME type del archivo
metadata: Metadatos adicionales
Returns:
bool: True si se subió correctamente
"""
try:
if file_path:
self._client.fput_object(
bucket_name=self._bucket_name,
object_name=object_name,
file_path=file_path,
content_type=content_type,
metadata=metadata
)
elif file_data:
self._client.put_object(
bucket_name=self._bucket_name,
object_name=object_name,
data=file_data,
length=-1,
part_size=10*1024*1024, # 10MB
content_type=content_type,
metadata=metadata
)
else:
raise ValueError("You must provide file_path or file_data")
return True
except S3Error as e:
return False
def get_file_url(self, object_name: str, expires: int = 3600) -> Optional[str]:
"""Genera una URL firmada para acceder al archivo"""
try:
url = self._client.presigned_get_object(
bucket_name=self._bucket_name,
object_name=object_name,
expires=timedelta(seconds=expires)
)
# Reemplazar endpoint interno por público si está configurado
public_endpoint = os.getenv('MINIO_PUBLIC_ENDPOINT')
if public_endpoint and url:
internal_endpoint = os.getenv('MINIO_ENDPOINT', 'minio:9000')
url = url.replace(internal_endpoint, public_endpoint)
return url
except S3Error as e:
return None
def delete_file(self, object_name: str) -> bool:
"""Elimina un archivo del bucket"""
try:
self._client.remove_object(
bucket_name=self._bucket_name,
object_name=object_name
)
return True
except S3Error as e:
return False
def file_exists(self, object_name: str) -> bool:
"""Verifica si un archivo existe en el bucket"""
try:
self._client.stat_object(
bucket_name=self._bucket_name,
object_name=object_name
)
return True
except S3Error:
return False
# Singleton para uso global
minio_client = MinIOClient()

View File

@@ -0,0 +1,628 @@
# backend/utils/storage_service.py
import os
import logging
import mimetypes
import shutil
from uuid import uuid4
from typing import Optional, Union, Literal
from pathlib import Path
from enum import Enum
from django.core.files.uploadedfile import UploadedFile
from django.conf import settings
from .minio_client import minio_client
logger = logging.getLogger(__name__)
class StorageCategory(str, Enum):
"""Categorías de almacenamiento disponibles"""
DOCUMENTS = "documents"
DATASTAGE = "datastage"
REPORTS = "reports"
VUCEM_CERTS = "vucem_certs"
VUCEM_KEYS = "vucem_keys"
class StorageService:
"""
Servicio para gestionar el almacenamiento de archivos.
Estructura aislada por organización:
org_{id}/
├── documents/{pedimento_app o unknown}/
├── datastage/
├── reports/
├── vucem_certs/
└── vucem_keys/
"""
def __init__(self):
self.client = minio_client
self.storage_backend = getattr(settings, 'STORAGE_BACKEND', 'local')
self.local_media_root = getattr(settings, 'MEDIA_ROOT', 'media')
self.debug = getattr(settings, 'DEBUG', False)
def _generate_filename(self, original_filename: str) -> str:
"""Genera un nombre de archivo único para evitar colisiones"""
name, ext = os.path.splitext(original_filename)
unique_id = str(uuid4())[:8]
return f"{name}_{unique_id}{ext}"
def _get_content_type(self, filename: str) -> Optional[str]:
"""Determina el content-type basado en la extensión del archivo"""
content_type, _ = mimetypes.guess_type(filename)
return content_type
def _sanitize_folder_name(self, name: str) -> str:
"""
Sanitizar nombres de carpetas reemplazando caracteres problematicos.
Los guiones (-) son validos.
"""
invalid_chars = '<>:"/\\|?*'
for char in invalid_chars:
name = name.replace(char, '_')
return name
def _build_base_path(self, organizacion_id: Union[int, str]) -> str:
"""Construye la ruta base para una organización"""
return f"org_{organizacion_id}"
def _build_document_path(
self,
organizacion_id: Union[int, str],
filename: str,
pedimento_app: Optional[str] = None
) -> str:
"""
Construye ruta para DOCUMENTS:
org_{id}/documents/{pedimento_app o unknown}/archivo
"""
base = self._build_base_path(organizacion_id)
safe_filename = self._generate_filename(filename)
if pedimento_app:
subfolder = self._sanitize_folder_name(pedimento_app)
else:
subfolder = "unknown"
return f"{base}/{StorageCategory.DOCUMENTS.value}/{subfolder}/{safe_filename}"
def _build_generic_path(
self,
organizacion_id: Union[int, str],
filename: str,
category: StorageCategory,
subfolder: Optional[str] = None
) -> str:
"""
Construye ruta para categorías genéricas:
org_{id}/{category}/{subfolder}/{archivo}
o
org_{id}/{category}/{archivo}
"""
base = self._build_base_path(organizacion_id)
safe_filename = self._generate_filename(filename)
if subfolder:
safe_subfolder = self._sanitize_folder_name(subfolder)
return f"{base}/{category.value}/{safe_subfolder}/{safe_filename}"
else:
return f"{base}/{category.value}/{safe_filename}"
def _save_file(
self,
file: UploadedFile,
object_path: str,
metadata: Optional[dict] = None
) -> Optional[str]:
"""Guarda el archivo según el backend configurado"""
meta = metadata or {}
meta['original_filename'] = file.name
content_type = self._get_content_type(file.name)
if self.storage_backend == 'minio':
return self._save_to_minio(file, object_path, content_type, meta)
else:
return self._save_to_local(file, object_path)
def _save_to_minio(
self,
file: UploadedFile,
object_path: str,
content_type: Optional[str],
metadata: dict
) -> Optional[str]:
"""Guarda archivo en MinIO"""
try:
file.seek(0)
success = self.client.upload_file(
object_name=object_path,
file_data=file,
content_type=content_type,
metadata=metadata
)
if success:
return object_path
else:
return None
except Exception as e:
return None
def _save_to_local(self, file: UploadedFile, object_path: str) -> Optional[str]:
"""Guarda archivo en sistema local"""
try:
full_path = Path(self.local_media_root) / object_path
full_path.parent.mkdir(parents=True, exist_ok=True)
with open(full_path, 'wb+') as destination:
for chunk in file.chunks():
destination.write(chunk)
return object_path
except Exception as e:
return None
def save_document(
self,
file: UploadedFile,
organizacion_id: Union[int, str],
pedimento_app: Optional[str] = None,
metadata: Optional[dict] = None
) -> Optional[str]:
"""
Guarda un documento en la categoría 'documents'.
Args:
file: Archivo a guardar
organizacion_id: ID de la organización (obligatorio)
pedimento_app: Identificador del pedimento (opcional, ej: '24-23-1653-4003611')
metadata: Metadatos adicionales
Returns:
str: Ruta guardada o None si hay error
Ejemplo:
save_document(file, 123, '24-23-1653-4003611')
'org_123/documents/24-23-1653-4003611/documento_a1b2c3d4.xml'
"""
if not file or not organizacion_id:
return None
object_path = self._build_document_path(organizacion_id, file.name, pedimento_app)
meta = metadata or {}
meta.update({
'category': StorageCategory.DOCUMENTS.value,
'organizacion_id': str(organizacion_id),
'pedimento_app': pedimento_app if pedimento_app else 'unknown'
})
return self._save_file(file, object_path, meta)
def save_document_from_path(
self,
file_path: str,
file_name: str,
organizacion_id: Union[int, str],
pedimento_app: Optional[str] = None,
metadata: Optional[dict] = None
) -> Optional[str]:
"""
Guarda un documento desde una ruta de archivo en disco.
Útil para archivos temporales ya extraídos.
Args:
file_path: Ruta completa del archivo en disco
file_name: Nombre del archivo
organizacion_id: ID de la organización
pedimento_app: Identificador del pedimento (opcional)
metadata: Metadatos adicionales
Returns:
str: Ruta guardada o None si hay error
"""
if not file_path or not os.path.exists(file_path):
return None
if not organizacion_id:
return None
base = self._build_base_path(organizacion_id)
safe_filename = self._generate_filename(file_name)
if pedimento_app:
subfolder = self._sanitize_folder_name(pedimento_app)
else:
subfolder = "unknown"
object_path = f"{base}/{StorageCategory.DOCUMENTS.value}/{subfolder}/{safe_filename}"
# Metadatos
meta = metadata or {}
meta.update({
'category': StorageCategory.DOCUMENTS.value,
'organizacion_id': str(organizacion_id),
'pedimento_app': pedimento_app if pedimento_app else 'unknown',
'original_filename': file_name
})
content_type = self._get_content_type(file_name)
# Guardar según backend
if self.storage_backend == 'minio':
try:
self.client._client.fput_object(
bucket_name=self.client._bucket_name,
object_name=object_path,
file_path=file_path,
content_type=content_type,
metadata=meta
)
return object_path
except Exception as e:
return None
else:
try:
dest_path = Path(self.local_media_root) / object_path
dest_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(file_path, dest_path)
return object_path
except Exception as e:
return None
def save_datastage(
self,
file: UploadedFile,
organizacion_id: Union[int, str],
subfolder: Optional[str] = None,
metadata: Optional[dict] = None
) -> Optional[str]:
"""
Guarda un archivo en la categoría 'datastage' (.zip, .jar, .rar, etc.)
Args:
file: Archivo a guardar
organizacion_id: ID de la organización
subfolder: Subcarpeta opcional dentro de datastage
metadata: Metadatos adicionales
Returns:
str: Ruta guardada o None si hay error
Ejemplo:
save_datastage(file, 123)
'org_123/datastage/proceso_a1b2c3d4.zip'
"""
if not file or not organizacion_id:
return None
object_path = self._build_generic_path(
organizacion_id, file.name, StorageCategory.DATASTAGE, subfolder
)
meta = metadata or {}
meta.update({
'category': StorageCategory.DATASTAGE.value,
'organizacion_id': str(organizacion_id)
})
if subfolder:
meta['subfolder'] = subfolder
return self._save_file(file, object_path, meta)
def save_report(
self,
file: UploadedFile,
organizacion_id: Union[int, str],
subfolder: Optional[str] = None,
metadata: Optional[dict] = None
) -> Optional[str]:
"""
Guarda un reporte en la categoría 'reports' (.pdf, .xlsx, etc.)
Args:
file: Archivo a guardar
organizacion_id: ID de la organización
subfolder: Subcarpeta opcional dentro de reports (ej: 'mensuales', '2025')
metadata: Metadatos adicionales
Returns:
str: Ruta guardada o None si hay error
Ejemplo:
>>> save_report(file, 123, '2025/enero')
'org_123/reports/2025/enero/reporte_x1y2z3w4.pdf'
"""
if not file or not organizacion_id:
return None
object_path = self._build_generic_path(
organizacion_id, file.name, StorageCategory.REPORTS, subfolder
)
meta = metadata or {}
meta.update({
'category': StorageCategory.REPORTS.value,
'organizacion_id': str(organizacion_id)
})
if subfolder:
meta['subfolder'] = subfolder
return self._save_file(file, object_path, meta)
def save_vucem_cert(
self,
file: UploadedFile,
organizacion_id: Union[int, str],
metadata: Optional[dict] = None
) -> Optional[str]:
"""
Guarda un certificado VUCEM en la categoría 'vucem_certs'.
Args:
file: Archivo de certificado
organizacion_id: ID de la organización
metadata: Metadatos adicionales
Returns:
str: Ruta guardada o None si hay error
Ejemplo:
>>> save_vucem_cert(file, 123)
'org_123/vucem_certs/certificado_a1b2c3d4.cer'
"""
if not file or not organizacion_id:
return None
object_path = self._build_generic_path(
organizacion_id, file.name, StorageCategory.VUCEM_CERTS
)
meta = metadata or {}
meta.update({
'category': StorageCategory.VUCEM_CERTS.value,
'organizacion_id': str(organizacion_id)
})
return self._save_file(file, object_path, meta)
def save_vucem_key(
self,
file: UploadedFile,
organizacion_id: Union[int, str],
metadata: Optional[dict] = None
) -> Optional[str]:
"""
Guarda una llave VUCEM en la categoría 'vucem_keys'.
Args:
file: Archivo de llave
organizacion_id: ID de la organización
metadata: Metadatos adicionales
Returns:
str: Ruta guardada o None si hay error
Ejemplo:
>>> save_vucem_key(file, 123)
'org_123/vucem_keys/llave_a1b2c3d4.key'
"""
if not file or not organizacion_id:
return None
object_path = self._build_generic_path(
organizacion_id, file.name, StorageCategory.VUCEM_KEYS
)
meta = metadata or {}
meta.update({
'category': StorageCategory.VUCEM_KEYS.value,
'organizacion_id': str(organizacion_id)
})
return self._save_file(file, object_path, meta)
def save_custom(
self,
file: UploadedFile,
organizacion_id: Union[int, str],
custom_path: str,
metadata: Optional[dict] = None
) -> Optional[str]:
"""
Guarda un archivo en una ruta personalizada dentro de la organización.
Args:
file: Archivo a guardar
organizacion_id: ID de la organización
custom_path: Ruta personalizada (se antepone org_{id}/)
metadata: Metadatos adicionales
Returns:
str: Ruta guardada o None si hay error
Ejemplo:
>>> save_custom(file, 123, 'temp/procesando/archivo.xml')
'org_123/temp/procesando/archivo_a1b2c3d4.xml'
"""
if not file or not organizacion_id:
return None
base = self._build_base_path(organizacion_id)
safe_filename = self._generate_filename(file.name)
# Combinar custom_path con el nombre del archivo
if custom_path.endswith('/'):
object_path = f"{base}/{custom_path}{safe_filename}"
else:
object_path = f"{base}/{custom_path}/{safe_filename}"
meta = metadata or {}
meta.update({
'organizacion_id': str(organizacion_id),
'custom_path': custom_path
})
return self._save_file(file, object_path, meta)
def get_file_url(self, object_path: str, expires: int = 3600) -> Optional[str]:
"""
Obtiene una URL para acceder al documento.
En desarrollo, reemplaza 'minio' por 'localhost' para acceso desde el navegador.
"""
if not object_path:
return None
if self.storage_backend == 'minio':
url = self.client.get_file_url(object_path, expires)
# En desarrollo, reemplazar 'minio:9000' por 'localhost:9000'
if url and self.debug:
url = url.replace('minio:9000', 'localhost:9000')
return url
else:
return f"{settings.MEDIA_URL}{object_path}"
def delete_file(self, object_path: str) -> bool:
"""Elimina un archivo"""
if self.storage_backend == 'minio':
return self.client.delete_file(object_path)
else:
try:
full_path = Path(self.local_media_root) / object_path
if full_path.exists():
full_path.unlink()
return True
return False
except Exception as e:
return False
def file_exists(self, object_path: str) -> bool:
"""Verifica si un archivo existe (MinIO o local)"""
if not object_path:
return False
# Si la ruta empieza con 'org_', es MinIO
if object_path.startswith('org_'):
if self.storage_backend == 'minio':
return self.client.file_exists(object_path)
else:
return (Path(self.local_media_root) / object_path).exists()
else:
# Ruta local antigua (ej: 'documents/archivo.xml')
# Siempre verificar en MEDIA_ROOT
return (Path(self.local_media_root) / object_path).exists()
def download_file(self, object_path: str, destination_path: str) -> bool:
"""
Descarga un archivo de MinIO al sistema de archivos local.
"""
if not object_path:
return False
if self.storage_backend == 'minio':
try:
self.client._client.fget_object(
bucket_name=self.client._bucket_name,
object_name=object_path,
file_path=destination_path
)
return True
except Exception as e:
return False
else:
import shutil
src = Path(self.local_media_root) / object_path
if src.exists():
shutil.copy(src, destination_path)
return True
return False
def is_minio_path(self, path):
if not path:
return False
return path.startswith('org_')
# =============================================================================================================
# POR AHORA NO FUERON SOLICITADOS PERO POR EL PROBLEMA DEL 15/04/2026, CONSIDERO PRUDENTE PODER TENER ESTOS
# DOS METODOS PARA NO COMPLICARNOS EN UN FUTURO, EN CASO DE SER NECESARIOS
# =============================================================================================================
# def delete_organization_folder(self, organizacion_id: Union[int, str]) -> bool:
# """
# Elimina TODOS los archivos de una organización.
# Útil cuando un cliente se va y necesitas borrar sus datos.
# Esta operación es IRREVERSIBLE.
# """
# prefix = f"org_{organizacion_id}/"
# if self.storage_backend == 'minio':
# try:
# objects = self.client._client.list_objects(self.client._bucket_name,prefix=prefix,recursive=True)
# for obj in objects:
# self.client.delete_file(obj.object_name)
# return True
# except Exception as e:
# return False
# else:
# try:
# import shutil
# full_path = Path(self.local_media_root) / f"org_{organizacion_id}"
# if full_path.exists():
# shutil.rmtree(full_path)
# return True
# except Exception as e:
# return False
# def export_organization_files(
# self,
# organizacion_id: Union[int, str],
# output_zip_path: str
# ) -> bool:
# """
# Exporta TODOS los archivos de una organización a un ZIP.
# Útil para entregar datos a un cliente que se va.
# Args:
# organizacion_id: ID de la organización
# output_zip_path: Ruta donde guardar el ZIP
# Returns: bool
# """
# import zipfile
# from io import BytesIO
# prefix = f"org_{organizacion_id}/"
# try:
# with zipfile.ZipFile(output_zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
# if self.storage_backend == 'minio':
# objects = self.client._client.list_objects(self.client._bucket_name,prefix=prefix,recursive=True)
# for obj in objects:
# response = self.client._client.get_object(self.client._bucket_name,obj.object_name)
# data = response.read()
# zip_path = obj.object_name.replace(prefix, '', 1)
# zipf.writestr(zip_path, data)
# response.close()
# else:
# local_path = Path(self.local_media_root) / f"org_{organizacion_id}"
# if local_path.exists():
# for file_path in local_path.rglob('*'):
# if file_path.is_file():
# zip_path = str(file_path.relative_to(local_path))
# zipf.write(file_path, zip_path)
# return True
# except Exception as e:
# return False
# Singleton para uso global
storage_service = StorageService()

View File

@@ -20,8 +20,10 @@ class Vucem(models.Model):
password = models.CharField(max_length=100, help_text="Contraseña de VUCEM") password = models.CharField(max_length=100, help_text="Contraseña de VUCEM")
patente = models.CharField(max_length=100, unique=True, help_text="Patente de VUCEM") patente = models.CharField(max_length=100, unique=True, help_text="Patente de VUCEM")
efirma = models.CharField(max_length=100, blank=True, null=True,help_text="E-Firma de VUCEM") efirma = models.CharField(max_length=100, blank=True, null=True,help_text="E-Firma de VUCEM")
key = models.FileField(upload_to='vucem_keys/', help_text="Llave privada de VUCEM") # key = models.FileField(upload_to='vucem_keys/', help_text="Llave privada de VUCEM")
cer = models.FileField(upload_to='vucem_certs/', help_text="Certificado de VUCEM") # cer = models.FileField(upload_to='vucem_certs/', help_text="Certificado de VUCEM")
key = models.CharField(max_length=500, blank=True, null=True, help_text="Llave privada de VUCEM")
cer = models.CharField(max_length=500, blank=True, null=True, help_text="Certificado de VUCEM")
is_importador = models.BooleanField(default=False, help_text="Indica si es importador") is_importador = models.BooleanField(default=False, help_text="Indica si es importador")
acusecove = models.BooleanField(default=False, help_text="Indica si generara acusecove") acusecove = models.BooleanField(default=False, help_text="Indica si generara acusecove")

View File

@@ -1,5 +1,6 @@
from api.utils.storage_service import storage_service
from rest_framework import serializers from rest_framework import serializers
from .models import Vucem, CredencialesImportador from .models import Vucem, CredencialesImportador
@@ -9,11 +10,91 @@ from .models import Vucem, CredencialesImportador
class VucemSerializer(serializers.ModelSerializer): class VucemSerializer(serializers.ModelSerializer):
importadores = serializers.SerializerMethodField() importadores = serializers.SerializerMethodField()
key = serializers.FileField(write_only=True, required=False, allow_null=True)
cer = serializers.FileField(write_only=True, required=False, allow_null=True)
key_download_url = serializers.SerializerMethodField(read_only=True)
cer_download_url = serializers.SerializerMethodField(read_only=True)
class Meta: class Meta:
model = Vucem model = Vucem
fields = '__all__' fields = '__all__'
read_only_fields = ('created_at', 'updated_at', 'organizacion', 'created_by', 'updated_by') read_only_fields = ('created_at', 'updated_at', 'organizacion', 'created_by', 'updated_by')
def get_key_download_url(self, obj):
if obj.key:
return storage_service.get_file_url(obj.key)
return None
def get_cer_download_url(self, obj):
if obj.cer:
return storage_service.get_file_url(obj.cer)
return None
def create(self, validated_data):
key_file = validated_data.pop('key', None)
cer_file = validated_data.pop('cer', None)
organizacion = validated_data.get('organizacion')
vucem = super().create(validated_data)
if key_file:
ruta = storage_service.save_vucem_key(
file=key_file,
organizacion_id=organizacion.id,
metadata={'vucem_id': str(vucem.id)}
)
if ruta:
vucem.key = ruta
else:
vucem.delete()
raise serializers.ValidationError({"key": "Error al guardar la llave"})
if cer_file:
ruta = storage_service.save_vucem_cert(
file=cer_file,
organizacion_id=organizacion.id,
metadata={'vucem_id': str(vucem.id)}
)
if ruta:
vucem.cer = ruta
else:
vucem.delete()
raise serializers.ValidationError({"cer_file": "Error al guardar el certificado"})
vucem.save()
return vucem
def update(self, instance, validated_data):
key_file = validated_data.pop('key', None)
cer_file = validated_data.pop('cer', None)
organizacion = validated_data.get('organizacion', instance.organizacion)
instance = super().update(instance, validated_data)
if key_file:
if instance.key:
storage_service.delete_file(str(instance.key))
ruta = storage_service.save_vucem_key(
file=key_file,
organizacion_id=organizacion.id
)
if ruta:
instance.key = ruta
if cer_file:
if instance.cer:
storage_service.delete_file(str(instance.cer))
ruta = storage_service.save_vucem_cert(
file=cer_file,
organizacion_id=organizacion.id
)
if ruta:
instance.cer = ruta
instance.save()
return instance
def get_importadores(self, obj): def get_importadores(self, obj):
# Importar aquí para evitar importación circular # Importar aquí para evitar importación circular
from api.customs.serializers import ImportadorSerializer from api.customs.serializers import ImportadorSerializer

View File

@@ -1,3 +1,7 @@
import atexit
import os
import tempfile
from django.shortcuts import render from django.shortcuts import render
from ..organization.models import Organizacion from ..organization.models import Organizacion
from rest_framework import viewsets from rest_framework import viewsets
@@ -8,6 +12,7 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
from django.http import FileResponse, Http404 from django.http import FileResponse, Http404
from api.utils.storage_service import storage_service
from .serializers import VucemSerializer, CredencialesImportadorSerializer, CredencialesImportadorSimpleSerializer from .serializers import VucemSerializer, CredencialesImportadorSerializer, CredencialesImportadorSimpleSerializer
from rest_framework import serializers from rest_framework import serializers
@@ -140,26 +145,53 @@ class VucemView(viewsets.ModelViewSet):
@action(detail=True, methods=["get"], permission_classes=[IsAuthenticated]) @action(detail=True, methods=["get"], permission_classes=[IsAuthenticated])
def download_cer(self, request, pk=None): def download_cer(self, request, pk=None):
"""
Descarga directa del archivo cer.
"""
vucem = self.get_object() vucem = self.get_object()
if not vucem.cer: if not vucem.cer:
return Response({"detail": "No hay archivo cer disponible."}, status=404) return Response({"detail": "No hay archivo cer disponible."}, status=404)
response = FileResponse(vucem.cer.open('rb'), as_attachment=True, filename=vucem.cer.name.split('/')[-1])
ruta = str(vucem.cer)
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)
atexit.register(lambda: os.unlink(tmp_path) if os.path.exists(tmp_path) else None)
return response return response
@action(detail=True, methods=["get"], permission_classes=[IsAuthenticated]) @action(detail=True, methods=["get"], permission_classes=[IsAuthenticated])
def download_key(self, request, pk=None): def download_key(self, request, pk=None):
"""
Descarga directa del archivo key.
"""
vucem = self.get_object() vucem = self.get_object()
if not vucem.key: if not vucem.key:
return Response({"detail": "No hay archivo key disponible."}, status=404) return Response({"detail": "No hay archivo key disponible."}, status=404)
response = FileResponse(vucem.key.open('rb'), as_attachment=True, filename=vucem.key.name.split('/')[-1])
ruta = str(vucem.key)
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)
atexit.register(lambda: os.unlink(tmp_path) if os.path.exists(tmp_path) else None)
return response return response
def perform_destroy(self, instance):
if instance.key:
storage_service.delete_file(str(instance.key))
if instance.cer:
storage_service.delete_file(str(instance.cer))
instance.delete()
class CredencialesImportadorViewSet(viewsets.ModelViewSet): class CredencialesImportadorViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]

View File

@@ -27,6 +27,7 @@ import re
# Celery Beat Schedule # Celery Beat Schedule
from celery.schedules import crontab from celery.schedules import crontab
from config.stg.storage import *
CELERY_BEAT_SCHEDULE = { CELERY_BEAT_SCHEDULE = {
@@ -85,6 +86,7 @@ THIRD_APPS = [
] ]
OWN_APPS = [ OWN_APPS = [
'api',
'api.customs', 'api.customs',
'api.record', 'api.record',
'api.organization', 'api.organization',
@@ -280,6 +282,9 @@ else:
STATICFILES_DIRS = [] STATICFILES_DIRS = []
STATIC_ROOT = BASE_DIR / 'static' STATIC_ROOT = BASE_DIR / 'static'
if STORAGE_BACKEND == 'minio':
MEDIA_URL = f"http://{os.getenv('MINIO_ENDPOINT')}/{AWS_STORAGE_BUCKET_NAME}/"
MEDIA_URL = '/media/' MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media' MEDIA_ROOT = BASE_DIR / 'media'

29
config/stg/storage.py Normal file
View File

@@ -0,0 +1,29 @@
# backend/config/stg/storage.py
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent.parent
STORAGE_BACKEND = os.getenv('STORAGE_BACKEND', 'local')
if STORAGE_BACKEND == 'minio':
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
AWS_ACCESS_KEY_ID = os.getenv('MINIO_ACCESS_KEY')
AWS_SECRET_ACCESS_KEY = os.getenv('MINIO_SECRET_KEY')
AWS_STORAGE_BUCKET_NAME = os.getenv('MINIO_BUCKET_NAME')
AWS_S3_ENDPOINT_URL = f"http://{os.getenv('MINIO_ENDPOINT')}"
AWS_S3_REGION_NAME = os.getenv('MINIO_REGION', 'us-east-1')
AWS_S3_USE_SSL = os.getenv('MINIO_SECURE', 'false').lower() == 'true'
AWS_DEFAULT_ACL = 'private'
AWS_LOCATION = 'documents'
AWS_S3_FILE_OVERWRITE = False
AWS_QUERYSTRING_AUTH = True
AWS_QUERYSTRING_EXPIRE = 3600 # es 1 hora
# STATICFILES_STORAGE = 'storages.backends.s3boto3.S3StaticStorage'
else:
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')

View File

@@ -1,12 +1,17 @@
alembic==1.14.0 alembic==1.14.0
amqp==5.3.1 amqp==5.3.1
annotated-types==0.7.0 annotated-types==0.7.0
argon2-cffi==25.1.0
argon2-cffi-bindings==25.1.0
asgiref==3.9.1 asgiref==3.9.1
async-timeout==5.0.1 async-timeout==5.0.1
attrs==25.3.0 attrs==25.3.0
billiard==4.2.1 billiard==4.2.1
boto3==1.42.91
botocore==1.42.91
celery==5.5.3 celery==5.5.3
certifi==2025.6.15 certifi==2025.6.15
cffi==2.0.0
channels==4.3.1 channels==4.3.1
channels_redis==4.3.0 channels_redis==4.3.0
charset-normalizer==3.4.2 charset-normalizer==3.4.2
@@ -18,6 +23,7 @@ Django==5.2.3
django-cors-headers==4.7.0 django-cors-headers==4.7.0
django-filter==25.1 django-filter==25.1
django-jet-reboot==1.3.10 django-jet-reboot==1.3.10
django-storages==1.14.6
djangorestframework==3.16.0 djangorestframework==3.16.0
djangorestframework_simplejwt==5.5.0 djangorestframework_simplejwt==5.5.0
drf-yasg==1.21.10 drf-yasg==1.21.10
@@ -30,12 +36,14 @@ humanize==4.12.3
idna==3.10 idna==3.10
importlib_resources==6.5.2 importlib_resources==6.5.2
inflection==0.5.1 inflection==0.5.1
jmespath==1.1.0
jsonschema==4.24.0 jsonschema==4.24.0
jsonschema-specifications==2025.4.1 jsonschema-specifications==2025.4.1
kombu==5.5.4 kombu==5.5.4
Mako==1.3.10 Mako==1.3.10
Markdown==3.8 Markdown==3.8
MarkupSafe==3.0.2 MarkupSafe==3.0.2
minio==7.2.20
msgpack==1.1.1 msgpack==1.1.1
openpyxl==3.1.5 openpyxl==3.1.5
packaging==25.0 packaging==25.0
@@ -44,6 +52,8 @@ pillow==11.2.1
prometheus_client==0.22.1 prometheus_client==0.22.1
prompt_toolkit==3.0.51 prompt_toolkit==3.0.51
psycopg2-binary==2.9.10 psycopg2-binary==2.9.10
pycparser==3.0
pycryptodome==3.23.0
PyJWT==2.9.0 PyJWT==2.9.0
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
python-dotenv==1.1.0 python-dotenv==1.1.0
@@ -55,6 +65,7 @@ redis==6.2.0
referencing==0.36.2 referencing==0.36.2
requests==2.32.4 requests==2.32.4
rpds-py==0.25.1 rpds-py==0.25.1
s3transfer==0.16.0
six==1.17.0 six==1.17.0
sniffio==1.3.1 sniffio==1.3.1
SQLAlchemy==2.0.36 SQLAlchemy==2.0.36