diff --git a/cosiap_api/common/views.py b/cosiap_api/common/views.py index 4ea3a50a61db6108a53cb29eea70a45f89bfc04c..07591b1aee65a28ade8b87e967cc0ba00c87de46 100644 --- a/cosiap_api/common/views.py +++ b/cosiap_api/common/views.py @@ -35,4 +35,4 @@ class BasePermissionAPIView(APIView): # por cada permiso dentro de la lista de permisos verificamos los permisos for permission in permissions: if not permission().has_permission(request, self): - self.permission_denied(request, message=getattr(permission, 'message', None)) \ No newline at end of file + self.permission_denied(request, message=getattr(permission, 'message', None)) diff --git a/cosiap_api/dynamic_tables/DynamicTable.py b/cosiap_api/dynamic_tables/DynamicTable.py index 250b935e32119c6e39f541080900def053647270..ae68c528b57da5f4d335f07b1ed6435832c213c1 100644 --- a/cosiap_api/dynamic_tables/DynamicTable.py +++ b/cosiap_api/dynamic_tables/DynamicTable.py @@ -6,6 +6,10 @@ from rest_framework import serializers from django.apps import apps from django.db.models import Q, Prefetch from .models import DynamicTableReport +from django.db import models +from collections import defaultdict +from django.db.models import ForeignKey, ManyToManyField, ManyToOneRel +import re class DynamicTable(serializers.ModelSerializer): ''' @@ -18,57 +22,297 @@ class DynamicTable(serializers.ModelSerializer): fields = '__all__' def get_data(self, obj): - ''' - Método que se encargará de recuperar los datos correspondientes del modelo enviado + ''' + Clase para obtener los datos solicitados en la configuración de reporte enviada - parámetros: - - obj: Configuración de reporte recibida, la cuál indica que datos se van a recuperar + param: + - obj: Configuración de reporte que contiene las columnas solicitadas, los filtros, exclusiones, etc... + ''' + try: + model = self.buscar_modelo(obj) + + columns = obj.columns + exclude_columns = obj.exclude_columns or [] + search_query = obj.search_query + filters = obj.filters or {} + exclude_filters = obj.exclude_filters or {} + + # Obtener los filtros disponibles para este modelo + available_filters = self.get_available_filters(obj) + + # Validar y limpiar los filtros enviados por la request + clean_filters = self.validate_and_clean_filters({'filters': filters}, available_filters) + + queryset = model.objects.all() + + # Aplicar filtros usando la función recursiva + queryset = self.apply_filters(queryset, clean_filters, exclude_filters) + + # Obtener los campos de tipo CharField o TextField del modelo y modelos relacionados + char_and_text_fields = self.get_char_and_text_fields(model) + related_char_and_text_fields = self.get_related_char_and_text_fields(model) + + # Aplicar búsqueda en campos de tipo CharField o TextField mediante el search_query + if search_query: + search_filters = [] + for field in char_and_text_fields + related_char_and_text_fields: + search_filters.append(Q(**{f"{field}__icontains": search_query})) + search_filter = Q() + for sf in search_filters: + search_filter |= sf + queryset = queryset.filter(search_filter) + + + # Seleccionar columnas a incluir y excluir + queryset = queryset.values(*[col for col in columns if col not in exclude_columns]) + + # Incluir campos relacionados + for field in model._meta.get_fields(): + if (field.is_relation and + (field.name not in exclude_columns) and + (field.related_model is not None)): + related_queryset = field.related_model.objects.all() + queryset = queryset.prefetch_related(Prefetch(field.name, queryset=related_queryset)) + + return list(queryset) + except Exception as e: + # Si hay algún fallo, regresamos una lista vacía + return [] + + + @staticmethod + def buscar_modelo(obj): + ''' + Método para obtener el modelo de la base de datos, buscando en las aplicaciones registradas. ''' - - # Primero tenemos que buscar el modelo en las aplicaciones registradas en el sistema - # Para asegurarnos de que el modelo sea único y sea el deseado. for app_config in apps.get_app_configs(): try: model = app_config.get_model(obj.model_name) break except LookupError: - continue + model = None else: - # Si no encontramos el modelo enviamos un error + model = None raise serializers.ValidationError(f"Model {obj.model_name} not found.") + return model + + + def get_available_filters(self, obj): + ''' + Método para obtener los filtros disponibles a aplicar a una configuración de tabla dinámica + con la finalidad de que dichos filtros puedan ser seleccionados desde el frontend - columns = obj.columns - exclude_columns = obj.exclude_columns or [] # Si es nulo, convertimos a una lista vacía para el mejor manejo - search_query = obj.search_query - filters = obj.filters or {} # si es nulo, convertimos a dict vacío para mejor manejo + parámetros: + - obj: objeto con la configuración actual de la tabla dinámica + ''' + available_filters = [] + + model = self.buscar_modelo(obj) + + for column in obj.columns: + field_path = column.split('__') + field = model + label = "" + for part in field_path: + if field._meta.get_field(part).is_relation: + field = field._meta.get_field(part).related_model + else: + field = field._meta.get_field(part) + label = field.verbose_name.capitalize() - queryset = model.objects.all() + filter_info = { + 'campo': column, + 'label': label or field.verbose_name.capitalize(), + 'html_type': '', + 'lookups': [], + } + # Verificamos el tipo de campo actual para poder enviar los filtros disponibles + # correctos según el tipo de campo, y el html_value correspondiente + if isinstance(field, models.CharField) or isinstance(field, models.TextField): + filter_info['html_type'] = 'textInput' + filter_info['lookups'] = ['icontains'] + # verificamos si existen choices para crear un nuevo diccionario "exact" con las keys y values + if field.choices: + filter_info['lookups'] = ['iexact'] + filter_info['choices'] = [{'label': choice[1], 'value': choice[0]} for choice in field.choices] + elif isinstance(field, models.IntegerField) or isinstance(field, models.FloatField) or isinstance(field, models.DecimalField): + filter_info['html_type'] = 'numberInput' + filter_info['lookups'] = ['gt', 'lt'] + elif isinstance(field, models.DateField) or isinstance(field, models.DateTimeField): + filter_info['html_type'] = 'dateInput' + filter_info['lookups'] = ['gt', 'lt', 'gte', 'lte'] + + available_filters.append(filter_info) + + return available_filters + + + @staticmethod + def validate_and_clean_filters(request_data, available_filters): + ''' + Función para validar y limpiar los filtros que nos ayudará a asegurarnos de que solo + se apliquen los filtros permitidos y limitar la cantidad de valores. + + parámetros: + - request_data: datos enviados en la solicitud + - available_filters: filtros disponibles previamente obtenidos + ''' + clean_filters = {} - # En este for, aplicamos los filtros envíados sobre el queryset - for key, value in filters.items(): - queryset = queryset.filter(**{key: value}) + for filtro_d in available_filters: + campo = filtro_d['campo'] + if campo in request_data['filters']: + # limpiamos el filtro + clean_filters[campo] = {} + for lookup in filtro_d['lookups']: + if lookup in request_data['filters'][campo]: + # vamos a poner un tope de 100 valores para evitar saturación + clean_filters[campo][lookup] = request_data['filters'][campo][lookup][:100] - # Aplicamos el searchquery enviado y extraemos la información de los campos - if search_query: - search_fields = [field for field in model._meta.fields if field.name in columns] - search_criteria = Q() - for field in search_fields: - search_criteria |= Q(**{f"{field.name}__icontains": search_query}) - queryset = queryset.filter(search_criteria) + return clean_filters - # Seleccionamos las columnas a incluir y las columnas a excluir - queryset = queryset.values(*[col for col in columns if col not in exclude_columns]) + def get_char_and_text_fields(self, model): + """ + Obtiene todos los campos de tipo CharField o TextField del modelo y sus campos relacionados. + + :param model: Modelo del cual obtener los campos. + :return: Lista de nombres de campos de tipo CharField o TextField. + """ + fields = [] + + for field in model._meta.get_fields(): + if isinstance(field, (models.CharField, models.TextField)): + fields.append(field.name) + elif isinstance(field, ForeignKey): + related_model = field.related_model + related_fields = self.get_char_and_text_fields(related_model) + fields.extend([f"{field.name}__{related_field}" for related_field in related_fields]) + + return fields + + + def get_related_char_and_text_fields(self, model): + """ + Obtiene todos los campos de tipo CharField o TextField de los modelos relacionados a través de llaves foráneas. + + :param model: Modelo del cual obtener los campos relacionados. + :return: Lista de nombres de campos de tipo CharField o TextField de modelos relacionados. + """ + fields = [] + + for field in model._meta.get_fields(): + if isinstance(field, ForeignKey): + related_model = field.related_model + related_fields = self.get_char_and_text_fields(related_model) + fields.extend([f"{field.name}__{related_field}" for related_field in related_fields]) + + return fields + + + + def get_available_columns(self, obj): + ''' + Método para obtener las columnas disponibles a solicitar de un determinado modelo (para la utilización de filtros y exclusiones) + + parámetros: + - obj: objeto de configuración de tabla dinámica + ''' + available_columns = {} + + model = self.buscar_modelo(obj) + if model is None: + # En caso de que no se haya encontrado el modelo enviamos un diccionario vacío + return available_columns + + available_columns = self.get_model_fields(model, set()) + return available_columns + + + + @staticmethod + def get_model_fields(model, visited_models): + ''' + Método recursivo para obtener los campos de un modelo, inclusive los relacionados, cuidando las relaciones muchos a muchos + + parámetros: + - model: modelo del cual obtener las columnas + - visited_models: conjunto para determinar si un modelo ya fue checkeado o no + ''' + if model in visited_models: + return {} + + # Una vez el modelo sea visitado lo guardamos en la lista + visited_models.add(model) + fields = {} + + # Expresión regular para excluir columnas específicas + exclude_pattern = re.compile(r'id|password|last_login|created_at|updated_at', re.IGNORECASE) - # Finalmente incluimos todos los campos que estén relacionados a los modelos por las llaves foráneas for field in model._meta.get_fields(): - if (field.is_relation and - (field.name not in exclude_columns) and - (field.related_model is not None)): - related_queryset = field.related_model.objects.all() - queryset = queryset.prefetch_related(Prefetch(field.name, queryset=related_queryset)) + # Verificar si el campo debe ser excluido + if exclude_pattern.search(field.name): + continue + + if isinstance(field, ForeignKey): + related_model = field.related_model + related_fields = DynamicTable.get_model_fields(related_model, visited_models) + for related_field_name, related_field_verbose_name in related_fields.items(): + if not exclude_pattern.search(related_field_name): + fields[f"{field.name}__{related_field_name}"] = f"{related_field_verbose_name}" + elif isinstance(field, ManyToManyField): + fields[field.name] = field.verbose_name + elif isinstance(field, ManyToOneRel): + continue # Omitir relaciones inversas + else: + fields[field.name] = field.verbose_name - # devolvemos el nuevo queryset con filtros y exclusiones realizadas - return list(queryset) + visited_models.remove(model) + return fields + + + @staticmethod + def apply_filters(queryset, filters, exclude_filters): + """ + Aplica filtros recursivamente a un queryset basado en un diccionario de filtros + y exclusiones. + + :param queryset: Queryset original al que se le aplican los filtros. + :param filters: Diccionario de filtros donde las claves son los campos y los valores son los criterios de filtro. + :param exclude_filters: Diccionario de filtros de exclusión donde las claves son los campos y los valores son los criterios de filtro. + + :return: Queryset filtrado según los filtros y las exclusiones especificadas. + """ + if not filters and not exclude_filters: + return queryset + + q_objects = Q() + exclude_q_objects = Q() + + # Aplicar filtros (OR dentro de un mismo campo, AND entre diferentes campos) + for field, lookup_values in filters.items(): + field_q_objects = Q() + for lookup, values in lookup_values.items(): + lookup_q_objects = Q() + for value in values: + lookup_q_objects |= Q(**{f"{field}__{lookup}": value}) + field_q_objects &= lookup_q_objects # AND dentro de un mismo campo + q_objects &= field_q_objects # AND entre diferentes campos + + # Aplicar filtros de exclusión (OR dentro de un mismo campo, AND entre diferentes campos) + for field, lookup_values in exclude_filters.items(): + field_exclude_q_objects = Q() + for lookup, values in lookup_values.items(): + lookup_exclude_q_objects = Q() + for value in values: + lookup_exclude_q_objects |= Q(**{f"{field}__{lookup}": value}) + field_exclude_q_objects |= lookup_exclude_q_objects # OR dentro de un mismo campo + exclude_q_objects &= field_exclude_q_objects # AND entre diferentes campos + + # Aplicar los filtros al queryset original + queryset = queryset.filter(q_objects).exclude(exclude_q_objects) + + return queryset + def create(self, validated_data): @@ -79,12 +323,19 @@ class DynamicTable(serializers.ModelSerializer): - validated_data: Datos validados según la configuración de la base de datos ''' - # Aquí usamos el get_or_create para asegurarnos de no guardar dos configuraciones que sean exactamente iguales - instance, created = DynamicTableReport.objects.get_or_create( + # Verificamos si ya existe una configuración igual en la base de datos + instance = DynamicTableReport.objects.filter( model_name=validated_data['model_name'], columns=validated_data['columns'], exclude_columns=validated_data.get('exclude_columns', None), search_query=validated_data.get('search_query', None), filters=validated_data.get('filters', None) - ) + ).first() + + if instance: + # Si ya existe una configuración igual, la retornamos + return instance + + # Si no existe, creamos una nueva instancia + instance = DynamicTableReport.objects.create(**validated_data) return instance diff --git a/cosiap_api/dynamic_tables/migrations/0002_columna_excludefilters_dynamictablereport.py b/cosiap_api/dynamic_tables/migrations/0002_columna_excludefilters_dynamictablereport.py new file mode 100644 index 0000000000000000000000000000000000000000..2a0743c9207ce8140ca91aa4d8999656cd18940b --- /dev/null +++ b/cosiap_api/dynamic_tables/migrations/0002_columna_excludefilters_dynamictablereport.py @@ -0,0 +1,22 @@ +# Generated by Django 5.0.6 on 2024-07-16 17:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dynamic_tables', '0001_creacion_tabla_dynamictablereport'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='dynamictablereport', + unique_together=set(), + ), + migrations.AddField( + model_name='dynamictablereport', + name='exlcude_filters', + field=models.JSONField(blank=True, null=True), + ), + ] diff --git a/cosiap_api/dynamic_tables/migrations/0003_columna_excludefilters_dynamictablereport.py b/cosiap_api/dynamic_tables/migrations/0003_columna_excludefilters_dynamictablereport.py new file mode 100644 index 0000000000000000000000000000000000000000..0903dd74282d3627a48f998d896421ac2dcb7e52 --- /dev/null +++ b/cosiap_api/dynamic_tables/migrations/0003_columna_excludefilters_dynamictablereport.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2024-07-16 17:09 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dynamic_tables', '0002_columna_excludefilters_dynamictablereport'), + ] + + operations = [ + migrations.RenameField( + model_name='dynamictablereport', + old_name='exlcude_filters', + new_name='exclude_filters', + ), + ] diff --git a/cosiap_api/dynamic_tables/models.py b/cosiap_api/dynamic_tables/models.py index cbd4ac818da8a7f026b4d4b7e241e02e8e229a7e..899b59bbbf1074a95aa142b9cf736298f2e62c81 100644 --- a/cosiap_api/dynamic_tables/models.py +++ b/cosiap_api/dynamic_tables/models.py @@ -16,11 +16,7 @@ class DynamicTableReport(models.Model): exclude_columns = models.JSONField(blank=True, null=True) search_query = models.CharField(max_length=100, blank=True, null=True) filters = models.JSONField(blank=True, null=True) - - # Incluimos la logica de unique para no guardar configuraciones que sean exactamente iguales - class Meta: - unique_together = ('model_name', 'columns', 'exclude_columns', 'search_query', 'filters') - + exclude_filters = models.JSONField(blank=True, null=True) def __str__(self): return 'Tabla Dinámica: ' + self.model_name diff --git a/cosiap_api/solicitudes/migrations/0003_crear_tabla_solicitud.py b/cosiap_api/solicitudes/migrations/0003_crear_tabla_solicitud.py new file mode 100644 index 0000000000000000000000000000000000000000..7af77e8fdb47947b8b6efcb7f96c430f88408ad5 --- /dev/null +++ b/cosiap_api/solicitudes/migrations/0003_crear_tabla_solicitud.py @@ -0,0 +1,66 @@ +# Generated by Django 5.0.6 on 2024-07-11 15:57 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('modalidades', '0001_creacion_inicial_modulos_dynamic_formats__modalidades__solicitudes'), + ('solicitudes', '0002_creacion_modelo_solicitud'), + ('users', '0010_creacion_modulos_admin_formats_forms_tables_modalidades_solicitudes'), + ] + + operations = [ + migrations.AlterField( + model_name='solicitud', + name='convenio', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='solicitudes.convenio', verbose_name='Convenio'), + ), + migrations.AlterField( + model_name='solicitud', + name='minuta', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='solicitudes.minuta', verbose_name='Minuta'), + ), + migrations.AlterField( + model_name='solicitud', + name='modalidad', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='modalidades.modalidad', verbose_name='Modalidad'), + ), + migrations.AlterField( + model_name='solicitud', + name='monto_aprobado', + field=models.FloatField(default=0.0, verbose_name='Monto Aprobado'), + ), + migrations.AlterField( + model_name='solicitud', + name='monto_solicitado', + field=models.FloatField(default=0.0, verbose_name='Monto Solicitado'), + ), + migrations.AlterField( + model_name='solicitud', + name='observacion', + field=models.TextField(blank=True, null=True, verbose_name='Observación'), + ), + migrations.AlterField( + model_name='solicitud', + name='solicitante', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='users.solicitante', verbose_name='Solicitante'), + ), + migrations.AlterField( + model_name='solicitud', + name='solicitud_n', + field=models.AutoField(primary_key=True, serialize=False, verbose_name='Num. Solicitud'), + ), + migrations.AlterField( + model_name='solicitud', + name='status', + field=models.CharField(max_length=255, verbose_name='Status'), + ), + migrations.AlterField( + model_name='solicitud', + name='timestamp', + field=models.DateTimeField(auto_now_add=True, verbose_name='Timestamp'), + ), + ] diff --git a/cosiap_api/solicitudes/tests.py b/cosiap_api/solicitudes/tests.py index 7ce503c2dd97ba78597f6ff6e4393132753573f6..5cf35fe63eb32804a74feefcedadc5839de021ae 100644 --- a/cosiap_api/solicitudes/tests.py +++ b/cosiap_api/solicitudes/tests.py @@ -1,3 +1,251 @@ +# tests.py from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient +from rest_framework import status +from users.models import Usuario, Solicitante, Municipio, Estado +from .models import Solicitud +from django.utils import timezone -# Create your tests here. + +class SolicitudTests(TestCase): + ''' + Clase de prueba de la lista de solicitudes usando DynamicTable + ''' + + def setUp(self): + """Configurar el entorno de prueba""" + self.client = APIClient() + self.usuario_data = { + 'curp': 'CEVA020423HGRRZDA8', + 'nombre': 'Adalberto', + 'email': 'adalc3488@gmail.com', + 'password': 'testpassword123' + } + self.usuario = Usuario.objects.create_superuser(**self.usuario_data) + self.usuario.is_active = True + self.usuario.is_staff = True + self.usuario.save() + + # Iniciar sesión + self.login_url = reverse('users:token_obtain') + response = self.client.post(self.login_url, { + 'curp': self.usuario_data['curp'], + 'password': self.usuario_data['password'] + }) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.access_token = response.data['access'] + self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {self.access_token}') + + # Configurar cookie de refresh token + refresh_token = response.cookies.get('refresh_token') + if refresh_token: + self.client.cookies['refresh_token'] = refresh_token.value + + # Crear una instancia de Estado + self.estado = Estado.objects.create(nombre="Test Estado") + + # Crear una instancia de Municipio + self.municipio = Municipio.objects.create( + estado=self.estado, + cve_mun=1, + nombre="Test Municipio" + ) + + # Crear una instancia de Solicitante + self.solicitante = Solicitante.objects.create( + curp="CEVA020423HGRRZDA9", + email="ceva@example.com", + nombre="Adalberto", + ap_paterno="Evans", + ap_materno="Vargas", + telefono="1234567890", + RFC="CEVA0204237E4", + direccion="Calle Falsa 123", + codigo_postal="12345", + municipio= self.municipio, + poblacion="Test Poblacion", + datos_bancarios=None, # Asignar datos bancarios si es necesario + INE=None # Asignar archivo de INE si es necesario + ) + + # Crear instancias de Solicitud usando la instancia de Solicitante + self.solicitud1 = Solicitud.objects.create( + solicitante=self.solicitante, + status="Pendiente", + solicitud_n="001", + monto_solicitado=1000, + monto_aprobado=800, + observacion="Observación 1", + # Asignar otros campos necesarios + ) + + self.solicitud2 = Solicitud.objects.create( + solicitante=self.solicitante, + status="Aprobado", + solicitud_n="002", + monto_solicitado=1500, + monto_aprobado=1200, + observacion="Observación 2", + # Asignar otros campos necesarios + ) + + def test_get_solicitudes(self): + """ + Probar que se pueden recuperar todas las solicitudes con una solicitud GET + """ + url = reverse('solicitudes:solicitud-list') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('data', response.data) + self.assertEqual(len(response.data['data']), 2) + + def test_post_solicitudes_exclusion_columnas(self): + """ + Probar la exclusión de columnas con una solicitud POST + """ + url = reverse('solicitudes:solicitud-list') + data = { + "model_name": "Solicitud", + "columns": [ + "status", + "solicitud_n", + "monto_solicitado", + "monto_aprobado", + "timestamp", + "solicitante__nombre" + ], + "exclude_columns": [ + "monto_solicitado" + ], + "filters": {}, + "exclude_filters": {}, + "search_query": "" + } + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('data', response.data) + self.assertNotIn('monto_solicitado', response.data['data'][0]) + + def test_post_solicitudes_con_filtros(self): + """ + Probar la aplicación de filtros con una solicitud POST + """ + url = reverse('solicitudes:solicitud-list') + data = { + "model_name": "Solicitud", + "columns": [ + "status", + "solicitud_n", + "monto_solicitado", + "monto_aprobado", + "timestamp", + "solicitante__nombre" + ], + "exclude_columns": [], + "filters": { + "status": { + "icontains": ["Pendiente"] + } + }, + "exclude_filters": {}, + "search_query": "" + } + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('data', response.data) + self.assertEqual(len(response.data['data']), 1) + self.assertEqual(response.data['data'][0]['status'], 'Pendiente') + + + def test_post_solicitudes_con_filtros_or(self): + """ + Probar la aplicación de filtros OR con una solicitud POST + """ + url = reverse('solicitudes:solicitud-list') + data = { + "model_name": "Solicitud", + "columns": [ + "status", + "solicitud_n", + "monto_solicitado", + "monto_aprobado", + "timestamp", + "solicitante__nombre" + ], + "exclude_columns": [], + "filters": { + "status": { + "icontains": ["Pendiente"] + }, + "solicitante__nombre":{ + "icontains": ["Adalberto", "Nombre_inexistente"] + } + }, + "exclude_filters": {}, + "search_query": "" + } + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('data', response.data) + self.assertEqual(len(response.data['data']), 1) + self.assertEqual(response.data['data'][0]['status'], 'Pendiente') + + def test_post_solicitudes_con_filtros_exclusion(self): + """ + Probar la aplicación de filtros de exclusión con una solicitud POST + """ + url = reverse('solicitudes:solicitud-list') + data = { + "model_name": "Solicitud", + "columns": [ + "status", + "solicitud_n", + "monto_solicitado", + "monto_aprobado", + "timestamp", + "solicitante__nombre" + ], + "exclude_columns": [], + "filters": {}, + "exclude_filters": { + "status": { + "icontains": ["Pendiente"] + } + }, + "search_query": "" + } + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('data', response.data) + self.assertEqual(len(response.data['data']), 1) + self.assertEqual(response.data['data'][0]['status'], 'Aprobado') + + def test_post_solicitudes_con_search_query(self): + """ + Probar la aplicación de un search_query con una solicitud POST + """ + url = reverse('solicitudes:solicitud-list') + data = { + "model_name": "Solicitud", + "columns": [ + "status", + "solicitud_n", + "monto_solicitado", + "monto_aprobado", + "timestamp", + "solicitante__nombre" + ], + "exclude_columns": [], + "filters": {}, + "exclude_filters": {}, + "search_query": "Adalberto" + } + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('data', response.data) + self.assertEqual(len(response.data['data']), 2) + self.assertEqual(response.data['data'][0]['solicitante__nombre'], 'Adalberto') + + + diff --git a/cosiap_api/solicitudes/urls.py b/cosiap_api/solicitudes/urls.py index 4a1b414ab49786b1a794e4bf24555619df47d843..2e64de1cf6dc6b25c88ea37040c0e29e0f5df901 100644 --- a/cosiap_api/solicitudes/urls.py +++ b/cosiap_api/solicitudes/urls.py @@ -4,4 +4,6 @@ from django.contrib.auth import views as auth_views app_name = 'solicitudes' -urlpatterns = [] \ No newline at end of file +urlpatterns = [ + path('', views.SolicitudAPIView.as_view(), name='solicitud-list'), + ] \ No newline at end of file diff --git a/cosiap_api/solicitudes/views.py b/cosiap_api/solicitudes/views.py index 91ea44a218fbd2f408430959283f0419c921093e..b1ac5ca1fb39a149246125db0dc00750a99d1605 100644 --- a/cosiap_api/solicitudes/views.py +++ b/cosiap_api/solicitudes/views.py @@ -1,3 +1,101 @@ +# Archivo con la vista para manejar las solicitudes +# Autores: Adalberto Cerrillo Vázquez +# Versión: 1.0 + from django.shortcuts import render +from users.views import BasePermissionAPIView +from rest_framework.response import Response +from rest_framework import status +from dynamic_tables.DynamicTable import DynamicTable +from rest_framework.permissions import AllowAny +from dynamic_tables.models import DynamicTableReport +from .models import Solicitud +from users.permisos import es_admin +from datetime import timedelta, datetime + + +class SolicitudAPIView(BasePermissionAPIView): + ''' + Clase para el manejo de la lista de solicitudes y la aplicación de sus filtros + + herencia: BasePermissionAPIView, clase con los permisos predeterminados para los usuarios + ''' + + permission_classes_list = [AllowAny] + permission_classes_create = [AllowAny] + + def get(self, request): + ''' + En la solicitud get manejamos la lógica para la lista de solicitudes mediante la tabla dinámica + ''' + + # Calculamos la fecha límite de 5 meses atrás desde hoy + fecha_limite = datetime.now() - timedelta(days=5*30) # Aproximadamente 5 meses + fecha_limite_str = fecha_limite.strftime('%Y-%m-%d') + + # Construimos la configuración del reporte sin guardarlo en la base de datos (para que no se guarde cada vez que se hace una nueva solicitud) + configuracion_reporte = DynamicTableReport( + model_name='Solicitud', + columns=[ + 'status', 'solicitud_n', 'minuta__archivo', 'convenio__archivo', 'monto_solicitado', 'monto_aprobado', + 'modalidad__nombre', 'modalidad__imagen', 'modalidad__descripcion', 'timestamp', 'observacion', + 'solicitante__nombre', 'solicitante__ap_paterno', 'solicitante__ap_materno', 'solicitante__telefono', + 'solicitante__RFC', 'solicitante__direccion', 'solicitante__codigo_postal', 'solicitante__municipio__nombre', + 'solicitante__municipio__estado__nombre', 'solicitante__poblacion', 'solicitante__INE', 'solicitante__datos_bancarios__nombre_banco', + 'solicitante__datos_bancarios__cuenta_bancaria', 'solicitante__datos_bancarios__clabe_bancaria', + 'solicitante__datos_bancarios__codigo_postal_fiscal', 'solicitante__datos_bancarios__regimen' + ], + exclude_columns=[], + search_query=None, + filters={ + 'timestamp': { + 'gte': [fecha_limite_str] + } + }, + exclude_filters = {} + ) + + # Inicializamos un objeto del tipo DynamicTable para obtener la lista + serializer = DynamicTable(instance=configuracion_reporte) + + # Obtenemos los datos usando el método get_data de nuestro serializer + data = serializer.get_data(configuracion_reporte) + + # Obtenemos los filtros disponibles a aplicar a los campos enviados + available_filters = serializer.get_available_filters(configuracion_reporte) + + # Obtenemos las columnas disponibles para nuestro modelo + available_columns = serializer.get_available_columns(configuracion_reporte) + + # Creamos un response data para devolver la data y los filtros disponibles + response_data = {'data': data, 'available_filters': available_filters, 'available_columns': available_columns} + + # Devolvemos la respuesta + return Response(response_data, status=status.HTTP_200_OK) + + + def post(self, request, *args, **kwargs): + ''' + En la solicitud post manejamos el caso del envio de filtros, exclusion de columnas y searchquerys + ''' + # Debemos inicializar el serializer con la request enviada + serializer = DynamicTable(data=request.data) + if serializer.is_valid(): + # Guarda la configuración si no existe ya + configuracion_reporte = serializer.save() + + # Recupera los datos según la configuración + data = serializer.get_data(configuracion_reporte) + + # Recuperamos los filtros disponibles de l configuración enviada + available_filters = serializer.get_available_filters(configuracion_reporte) + + # Obtenemos las columnas disponibles de nuestro modelo + available_columns = serializer.get_available_columns(configuracion_reporte) + + # creamos el response data para enviar la data y los filtros disponibles + response_data = {'data': data, 'available_filters': available_filters, 'available_columns': available_columns} -# Create your views here. + return Response(response_data, status=status.HTTP_200_OK) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/cosiap_api/users/migrations/0011_columna_excludefilters_dynamictablereport.py b/cosiap_api/users/migrations/0011_columna_excludefilters_dynamictablereport.py new file mode 100644 index 0000000000000000000000000000000000000000..b9e8414d708be0de06d684672e2c8e84a07d3bf1 --- /dev/null +++ b/cosiap_api/users/migrations/0011_columna_excludefilters_dynamictablereport.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2024-07-16 17:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0010_creacion_modulos_admin_formats_forms_tables_modalidades_solicitudes'), + ] + + operations = [ + migrations.AlterField( + model_name='datosbancarios', + name='regimen', + field=models.CharField(choices=[('1', 'Régimen Simplificado de Confianza'), ('2', 'Sueldos y salarios e ingresos asimilados a salarios'), ('3', 'Régimen de Actividades Empresariales y Profesionales'), ('4', 'Régimen de Incorporación Fiscal'), ('5', 'Enajenación de bienes'), ('6', 'Régimen de Actividades Empresariales con ingresos a través de Plataformas Tecnológicas'), ('7', 'Régimen de Arrendamiento'), ('8', 'Intereses'), ('9', 'Obtención de premios'), ('10', 'Dividendos'), ('11', 'Demás Ingresos'), ('12', 'Sin obligaciones fiscales')], max_length=255), + ), + ] diff --git a/cosiap_api/users/models.py b/cosiap_api/users/models.py index da9d7c8d71e8e9339632fabe7913d05ec9e3e54f..e51266956fd44862a24f8fb53dae6ab32efccc74 100644 --- a/cosiap_api/users/models.py +++ b/cosiap_api/users/models.py @@ -38,20 +38,21 @@ class Municipio(models.Model): # modelo para el registro de los datos bancarios del solicitante class DatosBancarios(models.Model): # Choices para el campo de régimen fiscal - REGIMEN_CHOICES= [ - ('1', 'Régminen Simplificado de Confianza'), - ('2', 'Sueldos y salarios e ingresos asimilados a salarios'), - ('3', 'Régimen de Actividades Empresariales y Profesionales'), - ('4', 'Régimen de Incorporación Fiscal'), - ('5', 'Enajenación de bienes'), - ('6', 'Régimen de Actividades Empresariales con ingresos a través de Plataformas Tecnológicas'), - ('7', 'Régimen de Arrendamiento'), - ('8', 'Intereses'), - ('9', 'Obtención de premios'), - ('10', 'Dividendos'), - ('11', 'Demás Ingresos'), - ('12', 'Sin obligaciones fiscales') + REGIMEN_CHOICES = [ + ('1', 'Régimen Simplificado de Confianza'), + ('2', 'Sueldos y salarios e ingresos asimilados a salarios'), + ('3', 'Régimen de Actividades Empresariales y Profesionales'), + ('4', 'Régimen de Incorporación Fiscal'), + ('5', 'Enajenación de bienes'), + ('6', 'Régimen de Actividades Empresariales con ingresos a través de Plataformas Tecnológicas'), + ('7', 'Régimen de Arrendamiento'), + ('8', 'Intereses'), + ('9', 'Obtención de premios'), + ('10', 'Dividendos'), + ('11', 'Demás Ingresos'), + ('12', 'Sin obligaciones fiscales') ] + # identificador del objeto id = models.BigAutoField(primary_key=True) # nombre del banco al que pertenece la cuenta @@ -124,8 +125,10 @@ class Usuario(AbstractBaseUser, PermissionsMixin): ordering = ['-is_superuser', 'id' ] -# clase Solicitante que hereda de Usuario class Solicitante(Usuario): + ''' + Calse para representar al solicitante (Hereda de Usuario) + ''' # validador para el formato del RFC RFC_REGEX = r'^([A-ZÑ&]{3,4}) ?(?:- ?)?(\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])) ?(?:- ?)?([A-Z\d]{2})([A\d])$'