fix/de los tickets T2026-05-027, T2025-09-004 y T2025-09-056
This commit is contained in:
@@ -25,7 +25,10 @@ from core.permissions import (
|
||||
require_permission,
|
||||
user_has_permission,
|
||||
)
|
||||
from .models import ReportDocument
|
||||
from .serializers import ExportModelSerializer
|
||||
from .services import datastage_export
|
||||
from .tasks.report_datastage import generate_report_datastage
|
||||
|
||||
def export_model_to_csv(request, model_name, fields, module='datastage', filters=None):
|
||||
model = apps.get_model(module, model_name)
|
||||
@@ -90,28 +93,13 @@ class ExportDataStageView(APIView):
|
||||
return [IsAuthenticated(), require_permission('reportes.view')()]
|
||||
return [IsAuthenticated(), require_permission('reportes.export')()]
|
||||
|
||||
# Constantes para partición
|
||||
# MAX_RECORDS_PER_FILE = 100 # Límite seguro por archivo
|
||||
MAX_RECORDS_PER_FILE = 120000 # Límite seguro por archivo
|
||||
# La lógica de exportación vive en services/datastage_export.py (la usa la
|
||||
# task Celery generate_report_datastage); estos delegados conservan la
|
||||
# interfaz para los métodos legacy de esta clase.
|
||||
MAX_RECORDS_PER_FILE = datastage_export.MAX_RECORDS_PER_FILE
|
||||
|
||||
def safe_excel_value(self, value):
|
||||
"""
|
||||
Convierte cualquier valor a un formato seguro para Excel
|
||||
"""
|
||||
if value is None:
|
||||
return ''
|
||||
elif isinstance(value, (uuid.UUID,)):
|
||||
return str(value)
|
||||
elif hasattr(value, 'uuid'):
|
||||
return str(value.uuid)
|
||||
elif hasattr(value, 'id'):
|
||||
return str(value.id)
|
||||
elif isinstance(value, (datetime.datetime, datetime.date)):
|
||||
return value.isoformat()
|
||||
elif isinstance(value, (dict, list)):
|
||||
return str(value)
|
||||
else:
|
||||
return str(value)
|
||||
return datastage_export.safe_excel_value(value)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Retorna RFCs distintos de Registro501 para la organización activa del usuario."""
|
||||
@@ -134,19 +122,67 @@ class ExportDataStageView(APIView):
|
||||
except LookupError:
|
||||
return Response({'rfcs': []})
|
||||
|
||||
@swagger_auto_schema(request_body=ExportModelSerializer, responses={200: 'Archivo generado (Excel o CSV)'})
|
||||
@swagger_auto_schema(request_body=ExportModelSerializer, responses={202: 'Reporte encolado (Celery)'})
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""
|
||||
Endpoint específico para exportación de DataStage con soporte múltiple
|
||||
Encola la generación asíncrona del reporte DataStage (Celery + SSE).
|
||||
Responde 202 con report_id y task_id; el progreso se sigue por SSE
|
||||
(/stream/tasks/{task_id}) y el archivo se descarga después vía
|
||||
/reports/report-document-download/{report_id}/.
|
||||
"""
|
||||
# Verificar si es modo múltiple
|
||||
modo = request.data.get('modo', 'simple')
|
||||
|
||||
export_format = request.data.get('format', 'csv')
|
||||
global_filters = request.data.get('globalFilters', {})
|
||||
|
||||
# Validar payload antes de encolar (mismos errores que el flujo síncrono)
|
||||
if modo == 'multiple':
|
||||
return self.handle_multiple_export(request)
|
||||
models_data = request.data.get('models', [])
|
||||
if not models_data:
|
||||
return Response({'error': 'models are required for multiple export'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
return self.handle_simple_export(request)
|
||||
|
||||
model_name = request.data.get('model')
|
||||
fields = request.data.get('fields')
|
||||
if not model_name or not fields:
|
||||
return Response({'error': 'model and fields are required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
try:
|
||||
apps.get_model('datastage', model_name)
|
||||
except LookupError:
|
||||
return Response({'error': f'Model {model_name} not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
global_filters, err = self._resolve_org_filter(global_filters, request.user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
# La org ya resuelta viaja en el payload: la task no tiene request.user
|
||||
payload = {
|
||||
'modo': modo,
|
||||
'format': export_format,
|
||||
'globalFilters': global_filters,
|
||||
'organizacion_id': global_filters.get('organizacion'),
|
||||
}
|
||||
if modo == 'multiple':
|
||||
payload['models'] = models_data
|
||||
else:
|
||||
payload['model'] = model_name
|
||||
payload['fields'] = fields
|
||||
|
||||
report = ReportDocument.objects.create(
|
||||
user=request.user,
|
||||
filters=payload,
|
||||
status='pending',
|
||||
report_type='datastage',
|
||||
)
|
||||
task = generate_report_datastage.delay(report.id)
|
||||
|
||||
return Response({
|
||||
'report_id': report.id,
|
||||
'task_id': task.id,
|
||||
'status': report.status,
|
||||
'created_at': report.created_at,
|
||||
'download_url': None,
|
||||
}, status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
|
||||
def _resolve_org_filter(self, global_filters, user):
|
||||
"""
|
||||
Devuelve los global_filters asegurando que siempre haya una organización.
|
||||
@@ -164,63 +200,6 @@ class ExportDataStageView(APIView):
|
||||
filters['organizacion'] = str(org.id)
|
||||
return filters, None
|
||||
|
||||
def handle_simple_export(self, request):
|
||||
"""Maneja exportación simple de DataStage (un solo modelo)"""
|
||||
model_name = request.data.get('model')
|
||||
fields = request.data.get('fields')
|
||||
global_filters = request.data.get('globalFilters', {})
|
||||
export_type = request.data.get('format', 'csv')
|
||||
module = 'datastage'
|
||||
|
||||
if not model_name or not fields:
|
||||
return Response({'error': 'model and fields are required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
global_filters, err = self._resolve_org_filter(global_filters, request.user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
try:
|
||||
model = apps.get_model(module, model_name)
|
||||
filters = self.apply_global_filters_to_model(global_filters, model, request.user)
|
||||
|
||||
queryset = model.objects.filter(**filters).values(*fields)
|
||||
total_records = queryset.count()
|
||||
|
||||
if export_type == 'excel':
|
||||
# Verificar si necesita partición
|
||||
if total_records > self.MAX_RECORDS_PER_FILE:
|
||||
return self.export_single_model_partitioned(request, model_name, fields, filters, total_records)
|
||||
else:
|
||||
return export_model_to_excel(request, model_name, fields, module, filters)
|
||||
else:
|
||||
if total_records > self.MAX_RECORDS_PER_FILE:
|
||||
return self.export_single_model_csv_partitioned(request, model_name, fields, filters, total_records)
|
||||
else:
|
||||
return export_model_to_csv(request, model_name, fields, module, filters)
|
||||
|
||||
except LookupError:
|
||||
return Response({'error': f'Model {model_name} not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def handle_multiple_export(self, request):
|
||||
"""Maneja exportación múltiple de DataStage (varios modelos)"""
|
||||
models_data = request.data.get('models', [])
|
||||
export_type = request.data.get('format', 'csv')
|
||||
global_filters = request.data.get('globalFilters', {})
|
||||
|
||||
if not models_data:
|
||||
return Response({'error': 'models are required for multiple export'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
global_filters, err = self._resolve_org_filter(global_filters, request.user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
related_keys = self.get_related_keys_from_filters(global_filters, models_data, request.user)
|
||||
|
||||
if export_type == 'excel':
|
||||
return self.export_datastage_multiple_partitioned_excel_agrupados(request, models_data, global_filters, related_keys)
|
||||
else:
|
||||
return self.export_datastage_multiple_to_csv_combined(request, models_data, global_filters, related_keys)
|
||||
|
||||
def estimate_total_records(self, models_data, global_filters, related_keys, user):
|
||||
"""Estima el total de registros para todos los modelos"""
|
||||
total = 0
|
||||
@@ -297,235 +276,6 @@ class ExportDataStageView(APIView):
|
||||
response['Content-Disposition'] = 'attachment; filename="datastage_related_report.xlsx"'
|
||||
return response
|
||||
|
||||
def export_datastage_multiple_partitioned_excel_agrupados(self, request, models_data, global_filters, related_keys):
|
||||
"""Exporta múltiples modelos de DataStage agrupados en la misma hoja de Excel, con particionado por límite de registros"""
|
||||
try:
|
||||
from api.organization.models import Organizacion
|
||||
org_mapping = {str(org.id): org.nombre for org in Organizacion.objects.all()}
|
||||
|
||||
# 1. Recopilar todos los datos FUERA del contexto ZIP
|
||||
all_models_data = {}
|
||||
model_field_mappings = {}
|
||||
|
||||
for model_data in models_data:
|
||||
model_name = model_data.get('model')
|
||||
fields = model_data.get('fields', [])
|
||||
|
||||
if not model_name or not fields:
|
||||
continue
|
||||
|
||||
normalized_fields = []
|
||||
for f in fields:
|
||||
try:
|
||||
key = f.strip() if isinstance(f, str) else f
|
||||
except Exception:
|
||||
key = f
|
||||
|
||||
if isinstance(key, str) and key.lower() == 'organizacion':
|
||||
if 'organizacion_id' not in normalized_fields:
|
||||
normalized_fields.append('organizacion_id')
|
||||
else:
|
||||
if key not in normalized_fields:
|
||||
normalized_fields.append(key)
|
||||
|
||||
fields = normalized_fields
|
||||
|
||||
required_fields = ['seccion_aduanera', 'patente', 'pedimento']
|
||||
for field in required_fields:
|
||||
if field not in fields:
|
||||
fields.append(field)
|
||||
|
||||
if 'organizacion_id' not in fields and 'organizacion_id' in [f.name for f in apps.get_model('datastage', model_name)._meta.get_fields()]:
|
||||
fields.append('organizacion_id')
|
||||
|
||||
try:
|
||||
model = apps.get_model('datastage', model_name)
|
||||
filters = self.apply_related_filters(global_filters, model, related_keys, request.user)
|
||||
|
||||
if filters:
|
||||
queryset = model.objects.filter(**filters).values(*fields)
|
||||
else:
|
||||
queryset = model.objects.none()
|
||||
|
||||
if queryset.count() == 0:
|
||||
continue
|
||||
|
||||
relation_fields = [fn for fn in ['seccion_aduanera', 'patente', 'pedimento'] if fn in fields]
|
||||
if not relation_fields:
|
||||
relation_fields = ['datastage_id'] if 'datastage_id' in fields else [fields[0]]
|
||||
|
||||
if model_name not in model_field_mappings:
|
||||
model_field_mappings[model_name] = fields
|
||||
|
||||
for record in queryset:
|
||||
key_parts = [str(record[rf]) for rf in relation_fields if rf in record and record[rf] is not None]
|
||||
if not key_parts:
|
||||
import hashlib
|
||||
key = hashlib.md5(str(sorted(record.items())).encode()).hexdigest()[:10]
|
||||
else:
|
||||
key = "_".join(key_parts)
|
||||
|
||||
processed_record = {}
|
||||
for field_name, value in record.items():
|
||||
if field_name == 'organizacion_id' and value:
|
||||
org_id_str = str(value)
|
||||
if org_id_str in org_mapping:
|
||||
processed_value = org_mapping[org_id_str]
|
||||
else:
|
||||
try:
|
||||
org = Organizacion.objects.filter(id=value).first()
|
||||
processed_value = org.nombre if org else org_id_str
|
||||
org_mapping[org_id_str] = processed_value
|
||||
except Exception:
|
||||
processed_value = org_id_str
|
||||
else:
|
||||
processed_value = value
|
||||
|
||||
if field_name in relation_fields:
|
||||
prefixed_field_name = field_name
|
||||
else:
|
||||
prefixed_field_name = f"{model_name}_{field_name}"
|
||||
|
||||
if field_name == 'organizacion_id':
|
||||
prefixed_field_name = prefixed_field_name.replace('organizacion_id', 'organizacion_nombre')
|
||||
|
||||
processed_record[prefixed_field_name] = self.safe_excel_value(processed_value)
|
||||
|
||||
if key not in all_models_data:
|
||||
all_models_data[key] = {'relation_fields': {}, 'model_records': {}}
|
||||
|
||||
for rel_field in relation_fields:
|
||||
if rel_field in record:
|
||||
all_models_data[key]['relation_fields'][rel_field] = record[rel_field]
|
||||
|
||||
if model_name not in all_models_data[key]['model_records']:
|
||||
all_models_data[key]['model_records'][model_name] = []
|
||||
|
||||
all_models_data[key]['model_records'][model_name].append(processed_record)
|
||||
|
||||
except LookupError:
|
||||
continue
|
||||
|
||||
# 2. Sin datos → Excel vacío (no JSON 404 que rompe la descarga en el frontend)
|
||||
if not all_models_data:
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Sin datos"
|
||||
ws.append(["No se encontraron datos para los filtros especificados"])
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
resp = HttpResponse(
|
||||
output.read(),
|
||||
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
)
|
||||
resp['Content-Disposition'] = 'attachment; filename="datastage_sin_datos.xlsx"'
|
||||
return resp
|
||||
|
||||
# 3. Construir filas combinadas — repetir el último registro en lugar de dejar vacíos
|
||||
combined_rows = []
|
||||
for key, data in all_models_data.items():
|
||||
relation_fields_data = data['relation_fields']
|
||||
model_records = data['model_records']
|
||||
|
||||
max_records_per_key = max((len(recs) for recs in model_records.values()), default=1)
|
||||
|
||||
for i in range(max_records_per_key):
|
||||
row_data = {}
|
||||
|
||||
for rel_field, rel_value in relation_fields_data.items():
|
||||
row_data[rel_field] = self.safe_excel_value(rel_value)
|
||||
|
||||
for model_name, records in model_records.items():
|
||||
# Usar posición i o el último registro disponible
|
||||
record = records[i] if i < len(records) else records[-1]
|
||||
for field_name, value in record.items():
|
||||
row_data[field_name] = value
|
||||
|
||||
combined_rows.append(row_data)
|
||||
|
||||
# 4. Encabezados ordenados
|
||||
all_fields_set = set()
|
||||
for row in combined_rows:
|
||||
all_fields_set.update(row.keys())
|
||||
|
||||
all_fields = []
|
||||
for rel_field in ['seccion_aduanera', 'patente', 'pedimento']:
|
||||
if rel_field in all_fields_set:
|
||||
all_fields.append(rel_field)
|
||||
all_fields_set.discard(rel_field)
|
||||
|
||||
org_fields = sorted(f for f in all_fields_set if 'organizacion' in f.lower())
|
||||
for org_field in org_fields:
|
||||
all_fields.append(org_field)
|
||||
all_fields_set.discard(org_field)
|
||||
|
||||
all_fields.extend(sorted(all_fields_set))
|
||||
|
||||
# 5. Filas de título y fecha de generación
|
||||
now_str = datetime.datetime.now().strftime('%d/%m/%Y %H:%M:%S')
|
||||
title_row = ["Reporte Datastage"]
|
||||
date_row = [f"Generado: {now_str}"]
|
||||
|
||||
def _write_sheet(ws, sheet_name, page_rows):
|
||||
ws.title = sheet_name[:31]
|
||||
ws.append(title_row)
|
||||
ws.append(date_row)
|
||||
ws.append([])
|
||||
ws.append(all_fields)
|
||||
for row_data in page_rows:
|
||||
ws.append([row_data.get(field, '') for field in all_fields])
|
||||
for column in ws.columns:
|
||||
max_length = 0
|
||||
col_letter = column[0].column_letter
|
||||
for cell in column:
|
||||
try:
|
||||
if len(str(cell.value)) > max_length:
|
||||
max_length = len(str(cell.value))
|
||||
except Exception:
|
||||
pass
|
||||
ws.column_dimensions[col_letter].width = min(max_length + 2, 50)
|
||||
|
||||
# 6. Excel directo si cabe en un archivo; ZIP solo si se necesita particionar
|
||||
from django.core.paginator import Paginator
|
||||
paginator = Paginator(combined_rows, self.MAX_RECORDS_PER_FILE)
|
||||
|
||||
if paginator.num_pages == 1:
|
||||
wb = openpyxl.Workbook()
|
||||
_write_sheet(wb.active, "Datastage", paginator.page(1).object_list)
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
resp = HttpResponse(
|
||||
output.read(),
|
||||
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
)
|
||||
resp['Content-Disposition'] = 'attachment; filename="datastage_reporte.xlsx"'
|
||||
return resp
|
||||
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
||||
for page_num in paginator.page_range:
|
||||
page = paginator.page(page_num)
|
||||
current_wb = openpyxl.Workbook()
|
||||
_write_sheet(current_wb.active, f"Datastage_p{page_num}", page.object_list)
|
||||
part_buffer = io.BytesIO()
|
||||
current_wb.save(part_buffer)
|
||||
part_buffer.seek(0)
|
||||
zip_file.writestr(f"datastage_part{page_num}.xlsx", part_buffer.getvalue())
|
||||
|
||||
zip_buffer.seek(0)
|
||||
resp = HttpResponse(zip_buffer.read(), content_type='application/zip')
|
||||
resp['Content-Disposition'] = 'attachment; filename="datastage_combinado.zip"'
|
||||
return resp
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
import logging
|
||||
logging.getLogger(__name__).error("Error en exportación combinada: %s", traceback.format_exc())
|
||||
return Response({'error': f'Error en exportación combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
def export_datastage_multiple_partitioned_excel_test_3(self, request, models_data, global_filters, related_keys):
|
||||
"""Exporta múltiples modelos de DataStage agrupados en la misma hoja de Excel, con particionado por límite de registros"""
|
||||
try:
|
||||
@@ -1215,144 +965,6 @@ class ExportDataStageView(APIView):
|
||||
except Exception as e:
|
||||
return Response({'error': f'Error en exportación particionada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
def export_datastage_multiple_to_csv_combined(self, request, models_data, global_filters, related_keys):
|
||||
"""Exporta múltiples modelos combinados en un único CSV plano (misma lógica de agrupación que el Excel)."""
|
||||
import hashlib
|
||||
import logging
|
||||
import traceback
|
||||
logger = logging.getLogger(__name__)
|
||||
try:
|
||||
from api.organization.models import Organizacion
|
||||
org_mapping = {str(org.id): org.nombre for org in Organizacion.objects.all()}
|
||||
|
||||
all_models_data = {}
|
||||
model_field_mappings = {}
|
||||
|
||||
for model_data in models_data:
|
||||
model_name = model_data.get('model')
|
||||
fields = model_data.get('fields', [])
|
||||
if not model_name or not fields:
|
||||
continue
|
||||
|
||||
normalized_fields = []
|
||||
for f in fields:
|
||||
key = f.strip() if isinstance(f, str) else f
|
||||
if isinstance(key, str) and key.lower() == 'organizacion':
|
||||
if 'organizacion_id' not in normalized_fields:
|
||||
normalized_fields.append('organizacion_id')
|
||||
else:
|
||||
if key not in normalized_fields:
|
||||
normalized_fields.append(key)
|
||||
fields = normalized_fields
|
||||
|
||||
for req_field in ['seccion_aduanera', 'patente', 'pedimento']:
|
||||
if req_field not in fields:
|
||||
fields.append(req_field)
|
||||
|
||||
try:
|
||||
model = apps.get_model('datastage', model_name)
|
||||
model_field_names = [f.name for f in model._meta.get_fields() if hasattr(f, 'name')]
|
||||
if 'organizacion_id' not in fields and 'organizacion_id' in model_field_names:
|
||||
fields.append('organizacion_id')
|
||||
|
||||
filters = self.apply_related_filters(global_filters, model, related_keys, request.user)
|
||||
queryset = model.objects.filter(**filters).values(*fields) if filters else model.objects.none()
|
||||
if queryset.count() == 0:
|
||||
continue
|
||||
|
||||
relation_fields = [fn for fn in ['seccion_aduanera', 'patente', 'pedimento'] if fn in fields]
|
||||
if not relation_fields:
|
||||
relation_fields = ['datastage_id'] if 'datastage_id' in fields else [fields[0]]
|
||||
|
||||
if model_name not in model_field_mappings:
|
||||
model_field_mappings[model_name] = fields
|
||||
|
||||
for record in queryset:
|
||||
key_parts = [str(record[rf]) for rf in relation_fields if rf in record and record[rf] is not None]
|
||||
key = "_".join(key_parts) if key_parts else hashlib.md5(str(sorted(record.items())).encode()).hexdigest()[:10]
|
||||
|
||||
processed_record = {}
|
||||
for field_name, value in record.items():
|
||||
if field_name == 'organizacion_id' and value:
|
||||
org_id_str = str(value)
|
||||
processed_value = org_mapping.get(org_id_str, org_id_str)
|
||||
else:
|
||||
processed_value = value
|
||||
|
||||
if field_name in relation_fields:
|
||||
prefixed = field_name
|
||||
else:
|
||||
prefixed = f"{model_name}_{field_name}"
|
||||
if field_name == 'organizacion_id':
|
||||
prefixed = prefixed.replace('organizacion_id', 'organizacion_nombre')
|
||||
processed_record[prefixed] = self.safe_excel_value(processed_value)
|
||||
|
||||
if key not in all_models_data:
|
||||
all_models_data[key] = {'relation_fields': {}, 'model_records': {}}
|
||||
for rel_field in relation_fields:
|
||||
if rel_field in record:
|
||||
all_models_data[key]['relation_fields'][rel_field] = record[rel_field]
|
||||
if model_name not in all_models_data[key]['model_records']:
|
||||
all_models_data[key]['model_records'][model_name] = []
|
||||
all_models_data[key]['model_records'][model_name].append(processed_record)
|
||||
|
||||
except LookupError:
|
||||
continue
|
||||
|
||||
# Sin datos → CSV con mensaje, no error HTTP
|
||||
if not all_models_data:
|
||||
buf = io.StringIO()
|
||||
csv.writer(buf).writerow(['No se encontraron datos para los filtros especificados'])
|
||||
resp = HttpResponse(buf.getvalue(), content_type='text/csv; charset=utf-8')
|
||||
resp['Content-Disposition'] = 'attachment; filename="datastage_sin_datos.csv"'
|
||||
return resp
|
||||
|
||||
# Construir filas planas
|
||||
combined_rows = []
|
||||
for key, data in all_models_data.items():
|
||||
relation_fields_data = data['relation_fields']
|
||||
model_records = data['model_records']
|
||||
max_records = max((len(recs) for recs in model_records.values()), default=1)
|
||||
for i in range(max_records):
|
||||
row_data = {}
|
||||
for rel_field, rel_value in relation_fields_data.items():
|
||||
row_data[rel_field] = self.safe_excel_value(rel_value)
|
||||
for mn, records in model_records.items():
|
||||
record = records[i] if i < len(records) else records[-1]
|
||||
for field_name, value in record.items():
|
||||
row_data[field_name] = value
|
||||
combined_rows.append(row_data)
|
||||
|
||||
# Encabezados: campos de relación primero, luego org, luego el resto
|
||||
all_fields_set = set()
|
||||
for row in combined_rows:
|
||||
all_fields_set.update(row.keys())
|
||||
|
||||
all_fields = []
|
||||
for rel_field in ['seccion_aduanera', 'patente', 'pedimento']:
|
||||
if rel_field in all_fields_set:
|
||||
all_fields.append(rel_field)
|
||||
all_fields_set.discard(rel_field)
|
||||
org_fields = sorted(f for f in all_fields_set if 'organizacion' in f.lower())
|
||||
for org_field in org_fields:
|
||||
all_fields.append(org_field)
|
||||
all_fields_set.discard(org_field)
|
||||
all_fields.extend(sorted(all_fields_set))
|
||||
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(all_fields)
|
||||
for row_data in combined_rows:
|
||||
writer.writerow([row_data.get(field, '') for field in all_fields])
|
||||
|
||||
resp = HttpResponse(buf.getvalue(), content_type='text/csv; charset=utf-8')
|
||||
resp['Content-Disposition'] = 'attachment; filename="datastage_reporte.csv"'
|
||||
return resp
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error en exportación CSV combinada: %s", traceback.format_exc())
|
||||
return Response({'error': f'Error en exportación CSV combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
def export_datastage_multiple_to_csv(self, request, models_data, global_filters, related_keys):
|
||||
"""Exporta múltiples modelos de DataStage a múltiples archivos CSV en ZIP"""
|
||||
zip_buffer = io.BytesIO()
|
||||
@@ -1472,254 +1084,14 @@ class ExportDataStageView(APIView):
|
||||
except Exception as e:
|
||||
return Response({'error': f'Error en exportación CSV particionada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
def export_single_model_partitioned(self, request, model_name, fields, filters, total_records):
|
||||
"""Exporta un solo modelo particionado a ZIP"""
|
||||
try:
|
||||
zip_buffer = io.BytesIO()
|
||||
module = 'datastage'
|
||||
|
||||
model = apps.get_model(module, model_name)
|
||||
queryset = model.objects.filter(**filters).values(*fields)
|
||||
|
||||
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
||||
from django.core.paginator import Paginator
|
||||
paginator = Paginator(queryset, self.MAX_RECORDS_PER_FILE)
|
||||
|
||||
for page_num in paginator.page_range:
|
||||
page = paginator.page(page_num)
|
||||
|
||||
# Crear Excel para esta parte
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = f"Parte_{page_num}"[:31]
|
||||
ws.append(fields)
|
||||
|
||||
for row in page.object_list:
|
||||
row_values = [self.safe_excel_value(row[field]) for field in fields]
|
||||
ws.append(row_values)
|
||||
|
||||
part_buffer = io.BytesIO()
|
||||
wb.save(part_buffer)
|
||||
part_buffer.seek(0)
|
||||
|
||||
filename = f"{model_name}_part{page_num}.xlsx"
|
||||
zip_file.writestr(filename, part_buffer.getvalue())
|
||||
|
||||
zip_buffer.seek(0)
|
||||
zip_content = zip_buffer.getvalue()
|
||||
|
||||
response = HttpResponse(zip_content, content_type='application/zip')
|
||||
response['Content-Disposition'] = f'attachment; filename="{model_name}_particionado.zip"'
|
||||
response['Content-Length'] = len(zip_content)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
return Response({'error': f'Error exportando modelo: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
def export_single_model_csv_partitioned(self, request, model_name, fields, filters, total_records):
|
||||
"""Exporta un solo modelo CSV particionado a ZIP"""
|
||||
try:
|
||||
zip_buffer = io.BytesIO()
|
||||
module = 'datastage'
|
||||
|
||||
model = apps.get_model(module, model_name)
|
||||
queryset = model.objects.filter(**filters).values(*fields)
|
||||
|
||||
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
||||
from django.core.paginator import Paginator
|
||||
paginator = Paginator(queryset, self.MAX_RECORDS_PER_FILE)
|
||||
|
||||
for page_num in paginator.page_range:
|
||||
page = paginator.page(page_num)
|
||||
|
||||
csv_buffer = io.StringIO()
|
||||
writer = csv.writer(csv_buffer)
|
||||
writer.writerow(fields)
|
||||
|
||||
for row in page.object_list:
|
||||
row_values = [self.safe_excel_value(row[field]) for field in fields]
|
||||
writer.writerow(row_values)
|
||||
|
||||
# Agregar al ZIP
|
||||
filename = f"{model_name}_part{page_num}.csv"
|
||||
zip_file.writestr(filename, csv_buffer.getvalue())
|
||||
|
||||
zip_buffer.seek(0)
|
||||
|
||||
zip_content = zip_buffer.getvalue()
|
||||
|
||||
response = HttpResponse(zip_content, content_type='application/zip')
|
||||
response['Content-Disposition'] = f'attachment; filename="{model_name}_particionado.zip"'
|
||||
response['Content-Length'] = len(zip_content)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
return Response({'error': f'Error exportando modelo CSV: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
def get_related_keys_from_filters(self, global_filters, models_data, user):
|
||||
"""
|
||||
Construye el conjunto de (patente, pedimento, datastage_id) que servirá como
|
||||
llave de cruce entre modelos.
|
||||
return datastage_export.get_related_keys_from_filters(global_filters, models_data)
|
||||
|
||||
Regla clave: si el filtro RFC está activo, solo los modelos que tienen el campo
|
||||
'rfc' pueden contribuir a related_keys. Los modelos sin 'rfc' (ej. 505, 506)
|
||||
no se usan como semilla — solo se filtrarán más tarde usando las claves ya
|
||||
construidas, evitando que contaminen el resultado con pedimentos de otros RFC.
|
||||
"""
|
||||
related_keys = {
|
||||
'patentes': set(),
|
||||
'pedimentos': set(),
|
||||
'datastage_ids': set()
|
||||
}
|
||||
|
||||
# Sin filtros significativos → sin cruce
|
||||
if not any(v for v in global_filters.values() if v not in [None, '']):
|
||||
return {}
|
||||
|
||||
rfc_filter_active = bool(global_filters.get('rfc'))
|
||||
date_filter_active = bool(global_filters.get('fecha_pago_desde') or global_filters.get('fecha_pago_hasta'))
|
||||
all_records_with_filters = []
|
||||
|
||||
for model_data in models_data:
|
||||
model_name = model_data.get('model')
|
||||
try:
|
||||
model = apps.get_model('datastage', model_name)
|
||||
model_field_names = {f.name for f in model._meta.get_fields() if hasattr(f, 'name')}
|
||||
|
||||
# Un modelo puede ser semilla de related_keys SOLO si tiene campos
|
||||
# para aplicar TODOS los filtros activos. Un modelo sin 'rfc' no puede
|
||||
# ser semilla cuando hay filtro de RFC (contaminaría con pedimentos de
|
||||
# otros RFCs). Igual para fecha_pago_real cuando hay filtro de fechas.
|
||||
if rfc_filter_active and 'rfc' not in model_field_names:
|
||||
continue
|
||||
if date_filter_active and 'fecha_pago_real' not in model_field_names:
|
||||
continue
|
||||
|
||||
filters = self.apply_global_filters_to_model(global_filters, model, user)
|
||||
if not filters:
|
||||
continue
|
||||
|
||||
records = model.objects.filter(**filters).values('patente', 'pedimento', 'datastage_id')
|
||||
all_records_with_filters.extend(list(records))
|
||||
|
||||
except LookupError:
|
||||
continue
|
||||
|
||||
if not all_records_with_filters:
|
||||
return {'patentes': set(), 'pedimentos': set(), 'datastage_ids': set()}
|
||||
|
||||
for record in all_records_with_filters:
|
||||
if record.get('patente'):
|
||||
related_keys['patentes'].add(record['patente'])
|
||||
if record.get('pedimento'):
|
||||
related_keys['pedimentos'].add(record['pedimento'])
|
||||
if record.get('datastage_id'):
|
||||
related_keys['datastage_ids'].add(record['datastage_id'])
|
||||
|
||||
return {k: list(v) for k, v in related_keys.items() if v}
|
||||
|
||||
def apply_global_filters_to_model(self, global_filters, model, user):
|
||||
"""
|
||||
Aplica filtros globales - VERSIÓN CORREGIDA CON UUID
|
||||
"""
|
||||
|
||||
filters = {}
|
||||
model_fields = [f.name for f in model._meta.get_fields()]
|
||||
|
||||
# ORGANIZACIÓN - Manejar como UUID
|
||||
org_value = global_filters.get('organizacion')
|
||||
if org_value and org_value != '' and 'organizacion' in model_fields:
|
||||
field = model._meta.get_field('organizacion')
|
||||
|
||||
if hasattr(field, 'related_model'): # Es ForeignKey
|
||||
# Convertir string a UUID
|
||||
try:
|
||||
import uuid
|
||||
org_uuid = uuid.UUID(org_value)
|
||||
filters['organizacion_id'] = org_uuid
|
||||
except Exception as e:
|
||||
# Fallback: dejar como string (puede no funcionar)
|
||||
filters['organizacion_id'] = org_value
|
||||
else: # Es CharField
|
||||
filters['organizacion'] = org_value
|
||||
|
||||
# RFC - Manejar normalmente
|
||||
rfc_value = global_filters.get('rfc')
|
||||
if rfc_value and rfc_value != '' and 'rfc' in model_fields:
|
||||
filters['rfc'] = rfc_value
|
||||
|
||||
# PATENTE
|
||||
if global_filters.get('patente'):
|
||||
filters['patente'] = global_filters['patente']
|
||||
|
||||
# PEDIMENTO
|
||||
if global_filters.get('pedimento'):
|
||||
filters['pedimento'] = global_filters['pedimento']
|
||||
|
||||
# FECHAS
|
||||
if 'fecha_pago_real' in model_fields:
|
||||
if global_filters.get('fecha_pago_desde'):
|
||||
filters['fecha_pago_real__gte'] = global_filters['fecha_pago_desde']
|
||||
|
||||
if global_filters.get('fecha_pago_hasta'):
|
||||
filters['fecha_pago_real__lte'] = global_filters['fecha_pago_hasta']
|
||||
|
||||
return filters
|
||||
|
||||
return datastage_export.apply_global_filters_to_model(global_filters, model)
|
||||
|
||||
def apply_related_filters(self, global_filters, model, related_keys, user):
|
||||
filters = {}
|
||||
model_fields = [f.name for f in model._meta.get_fields()]
|
||||
|
||||
# 1. Organización — convertir a UUID igual que apply_global_filters_to_model
|
||||
if 'organizacion' in model_fields and global_filters.get('organizacion'):
|
||||
org_value = global_filters['organizacion']
|
||||
try:
|
||||
field = model._meta.get_field('organizacion')
|
||||
if hasattr(field, 'related_model'):
|
||||
filters['organizacion_id'] = uuid.UUID(org_value)
|
||||
else:
|
||||
filters['organizacion'] = org_value
|
||||
except Exception:
|
||||
filters['organizacion_id'] = org_value
|
||||
|
||||
# 2. RFC (¡ESTO ES LO QUE FALTA!)
|
||||
if 'rfc' in model_fields and global_filters.get('rfc'):
|
||||
filters['rfc'] = global_filters['rfc']
|
||||
|
||||
# 3. Fechas (SIEMPRE se aplican)
|
||||
if 'fecha_pago_real' in model_fields:
|
||||
if global_filters.get('fecha_pago_desde'):
|
||||
filters['fecha_pago_real__gte'] = global_filters['fecha_pago_desde']
|
||||
|
||||
if global_filters.get('fecha_pago_hasta'):
|
||||
filters['fecha_pago_real__lte'] = global_filters['fecha_pago_hasta']
|
||||
|
||||
# 🔥 SEGUNDO: Si hay related_keys, AÑADIRLAS a los filtros existentes
|
||||
if any(related_keys.values()):
|
||||
|
||||
# Añadir patentes si existen
|
||||
if related_keys.get('patentes') and 'patente' in model_fields:
|
||||
filters['patente__in'] = related_keys['patentes']
|
||||
|
||||
# Añadir pedimentos si existen
|
||||
if related_keys.get('pedimentos') and 'pedimento' in model_fields:
|
||||
filters['pedimento__in'] = related_keys['pedimentos']
|
||||
|
||||
# Añadir datastage_ids si existen
|
||||
if related_keys.get('datastage_ids') and 'datastage_id' in model_fields:
|
||||
filters['datastage_id__in'] = related_keys['datastage_ids']
|
||||
|
||||
else:
|
||||
# Solo patente y pedimento específicos (no listas)
|
||||
if 'patente' in model_fields and global_filters.get('patente'):
|
||||
filters['patente'] = global_filters['patente']
|
||||
|
||||
if 'pedimento' in model_fields and global_filters.get('pedimento'):
|
||||
filters['pedimento'] = global_filters['pedimento']
|
||||
|
||||
return filters
|
||||
return datastage_export.apply_related_filters(global_filters, model, related_keys)
|
||||
|
||||
def estimate_excel_file_size(self, num_records, num_columns):
|
||||
"""Estima tamaño aproximado del archivo Excel"""
|
||||
|
||||
Reference in New Issue
Block a user