From b75ead817e0672e1df21c4362c856ce99f12370d Mon Sep 17 00:00:00 2001 From: AdalbertoCV <34152734@uaz.edu.mx> Date: Mon, 2 Sep 2024 17:59:27 -0600 Subject: [PATCH] =?UTF-8?q?Funcionalidades=20de=20formatos=20din=C3=A1mico?= =?UTF-8?q?s=20implementadas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cosiap_api/.dockerignore | 4 +- cosiap_api/common/delete_old_files.py | 18 +++ cosiap_api/cosiap_api/urls.py | 1 + cosiap_api/dynamic_formats/models.py | 8 ++ cosiap_api/dynamic_formats/serializer.py | 19 +++ cosiap_api/dynamic_formats/tests.py | 91 ++++++++++++++- cosiap_api/dynamic_formats/urls.py | 10 ++ cosiap_api/dynamic_formats/views.py | 141 ++++++++++++++++++++++- cosiap_api/dynamic_tables/filtros.py | 96 --------------- cosiap_api/entrypoint.sh | 1 - cosiap_api/modalidades/models.py | 16 +-- cosiap_api/requirements.txt | 6 +- cosiap_api/solicitudes/views.py | 3 +- cosiap_api/users/models.py | 16 +++ 14 files changed, 316 insertions(+), 114 deletions(-) create mode 100644 cosiap_api/common/delete_old_files.py create mode 100644 cosiap_api/dynamic_formats/serializer.py create mode 100644 cosiap_api/dynamic_formats/urls.py delete mode 100644 cosiap_api/dynamic_tables/filtros.py diff --git a/cosiap_api/.dockerignore b/cosiap_api/.dockerignore index e0cc78f..f1a9077 100644 --- a/cosiap_api/.dockerignore +++ b/cosiap_api/.dockerignore @@ -1,5 +1,5 @@ **/__pycache__ -**/.venv +**/venv **/.classpath **/.dockerignore **/.env @@ -24,4 +24,4 @@ **/secrets.dev.yaml **/values.dev.yaml LICENSE -README.md +README.md \ No newline at end of file diff --git a/cosiap_api/common/delete_old_files.py b/cosiap_api/common/delete_old_files.py new file mode 100644 index 0000000..9d94234 --- /dev/null +++ b/cosiap_api/common/delete_old_files.py @@ -0,0 +1,18 @@ +import os +from django.db.models.signals import pre_save +from django.dispatch import receiver + +def borrar_archivo_viejo(sender, instance, field_name, **kwargs): + # Verifica si el objeto ya existe (es un update) + if instance.pk: + try: + # Obtiene la instancia antigua del modelo + old_instance = sender.objects.get(pk=instance.pk) + old_file = getattr(old_instance, field_name) + new_file = getattr(instance, field_name) + + if old_file and old_file != new_file: + if os.path.isfile(old_file.path): + os.remove(old_file.path) + except sender.DoesNotExist: + pass \ No newline at end of file diff --git a/cosiap_api/cosiap_api/urls.py b/cosiap_api/cosiap_api/urls.py index b881abe..3161d36 100644 --- a/cosiap_api/cosiap_api/urls.py +++ b/cosiap_api/cosiap_api/urls.py @@ -29,6 +29,7 @@ urlpatterns = [ path('api/solicitudes/',include('solicitudes.urls')), path('api/dynamic-tables/',include('dynamic_tables.urls')), path('api/formularios/',include('dynamic_forms.urls')), + path('api/plantillas/',include('dynamic_formats.urls')), # API Doc UI: diff --git a/cosiap_api/dynamic_formats/models.py b/cosiap_api/dynamic_formats/models.py index 222fae0..7e1cca4 100644 --- a/cosiap_api/dynamic_formats/models.py +++ b/cosiap_api/dynamic_formats/models.py @@ -1,5 +1,8 @@ from django.db import models from common.nombres_archivos import nombre_archivo_formato +from django.db.models.signals import pre_save +from django.dispatch import receiver +from common.delete_old_files import borrar_archivo_viejo class DynamicFormat(models.Model): ''' @@ -20,3 +23,8 @@ class DynamicFormat(models.Model): verbose_name = "Formato Dinámico" verbose_name_plural = "Formatos Dinámicos" ordering = ['nombre'] + + +@receiver(pre_save, sender= DynamicFormat) +def borrar_template(sender, instance, **kwargs): + borrar_archivo_viejo(sender, instance, field_name='template', **kwargs) diff --git a/cosiap_api/dynamic_formats/serializer.py b/cosiap_api/dynamic_formats/serializer.py new file mode 100644 index 0000000..11eb9b0 --- /dev/null +++ b/cosiap_api/dynamic_formats/serializer.py @@ -0,0 +1,19 @@ +from rest_framework import serializers +from .models import DynamicFormat + +class DynamicFormatSerializer(serializers.ModelSerializer): + ''' + Clase para serializar los formatos dinámicos y manejar la lógica creación/edición + ''' + class Meta: + model = DynamicFormat + fields = ['id', 'nombre', 'template'] + + def create(self, validated_data): + return DynamicFormat.objects.create(**validated_data) + + def update(self, instance, validated_data): + instance.nombre = validated_data.get('nombre', instance.nombre) + instance.template = validated_data.get('template', instance.template) + instance.save() + return instance diff --git a/cosiap_api/dynamic_formats/tests.py b/cosiap_api/dynamic_formats/tests.py index 7ce503c..bdd0386 100644 --- a/cosiap_api/dynamic_formats/tests.py +++ b/cosiap_api/dynamic_formats/tests.py @@ -1,3 +1,90 @@ -from django.test import TestCase +from rest_framework.test import APITestCase, APIClient +from rest_framework import status +from django.urls import reverse +from django.core.files.uploadedfile import SimpleUploadedFile +from .models import DynamicFormat +from users.models import Usuario + +class FormatoAPIViewTestCase(APITestCase): + def setUp(self): + self.client = APIClient() + + # Crear un usuario admin para las pruebas + 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 un archivo de ejemplo para el formato + self.pdf_file = SimpleUploadedFile("template1.pdf", b"%PDF-1.4 contenido del PDF") + + # Crear un objeto de DynamicFormat para pruebas + self.dynamic_format = DynamicFormat.objects.create(nombre='Formato 1', template=self.pdf_file) + + # URL base de la API + self.list_url = reverse('dynamic_formats:formatos') + self.detail_url = reverse('dynamic_formats:formatos_pk', args=[self.dynamic_format.id]) + + def test_get_formatos(self): + # Prueba para obtener la lista de formatos + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + def test_create_formato(self): + # Prueba para crear un nuevo formato + data = { + 'nombre': 'Nuevo Formato', + 'template': SimpleUploadedFile("template2.pdf", b"%PDF-1.4 contenido del PDF nuevo") + } + response = self.client.post(self.list_url, data, format='multipart') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(DynamicFormat.objects.count(), 2) + + def test_create_formato_invalid_file(self): + # Prueba para intentar crear un formato con un archivo no PDF + data = { + 'nombre': 'Formato con archivo inválido', + 'template': SimpleUploadedFile("template3.txt", b"Contenido no PDF") + } + response = self.client.post(self.list_url, data, format='multipart') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_update_formato(self): + # Prueba para actualizar un formato existente + data = { + 'nombre': 'Formato Actualizado', + 'template': SimpleUploadedFile("template_updated.pdf", b"%PDF-1.4 contenido actualizado") + } + response = self.client.put(self.detail_url, data, format='multipart') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.dynamic_format.refresh_from_db() + self.assertEqual(self.dynamic_format.nombre, 'Formato Actualizado') + + def test_delete_formato(self): + # Prueba para eliminar un formato + response = self.client.delete(self.detail_url) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(DynamicFormat.objects.count(), 0) -# Create your tests here. diff --git a/cosiap_api/dynamic_formats/urls.py b/cosiap_api/dynamic_formats/urls.py new file mode 100644 index 0000000..5c7db51 --- /dev/null +++ b/cosiap_api/dynamic_formats/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from dynamic_formats import views + +app_name = 'dynamic_formats' + +urlpatterns = [ + path('', views.FormatoAPIView.as_view(), name='formatos'), + path('/', views.FormatoAPIView.as_view(), name='formatos_pk'), + path('download//', views.DescargarFormatoView.as_view(), name='descargar_formato'), +] \ No newline at end of file diff --git a/cosiap_api/dynamic_formats/views.py b/cosiap_api/dynamic_formats/views.py index 91ea44a..c515eea 100644 --- a/cosiap_api/dynamic_formats/views.py +++ b/cosiap_api/dynamic_formats/views.py @@ -1,3 +1,142 @@ from django.shortcuts import render +from common.views import BasePermissionAPIView +from rest_framework.permissions import IsAuthenticated +from users.permisos import es_admin +from rest_framework.response import Response +from rest_framework import status +from rest_framework.generics import get_object_or_404 +from .models import DynamicFormat +from users.models import Solicitante +from .serializer import DynamicFormatSerializer +from django.http import HttpResponse +from io import BytesIO +from docx import Document +import re +from notificaciones.mensajes import Mensaje +import logging -# Create your views here. +class FormatoAPIView(BasePermissionAPIView): + ''' + Clase para manejar los formatos dinámicos + ''' + + serializer_class = DynamicFormatSerializer + + # Definición de permisos según la acción + permission_classes_list = [IsAuthenticated, es_admin] + permission_classes_create = [IsAuthenticated, es_admin] + permission_classes_update = [IsAuthenticated, es_admin] + permission_classes_delete = [IsAuthenticated, es_admin] + + def get(self, request, pk=None, *args, **kwargs): + formatos = DynamicFormat.objects.all() + serializer = self.serializer_class(formatos, many=True) + return Response(serializer.data) + + def post(self, request, *args, **kwargs): + # Crear un nuevo formato + serializer = self.serializer_class(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def put(self, request, pk=None, *args, **kwargs): + # Actualizar un formato existente + formato = get_object_or_404(DynamicFormat, pk=pk) + serializer = self.serializer_class(formato, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, pk=None, *args, **kwargs): + # Eliminar un formato + formato = get_object_or_404(DynamicFormat, pk=pk) + formato.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +# Configuración básica de logging +logging.basicConfig(level=logging.DEBUG) + +class DescargarFormatoView(BasePermissionAPIView): + ''' + Clase para manejar las descargas de los formatos dinámicos en DOCX. + ''' + permission_classes_list = [IsAuthenticated] + + def get(self, request, *args, **kwargs): + try: + formato_id = kwargs['pk'] + user = request.user + solicitante = get_object_or_404(Solicitante, id=user.id) + + # Obtén el formato dinámico + formato = get_object_or_404(DynamicFormat, id=formato_id) + + logging.debug(f'Formato ID: {formato_id}') + logging.debug(f'Solicitante ID: {solicitante.id}, Nombre: {solicitante.nombre}') + + # Cargar el archivo DOCX del formato + doc_template_path = formato.template.path + logging.debug(f'Ruta del archivo DOCX: {doc_template_path}') + + doc = Document(doc_template_path) + + # Reemplazar variables en el texto del DOCX + for para in doc.paragraphs: + logging.debug(f'Original paragraph text: {para.text}') + para.text = self.reemplazar_variables(para.text, solicitante) + logging.debug(f'Replaced paragraph text: {para.text}') + + for table in doc.tables: + for row in table.rows: + for cell in row.cells: + logging.debug(f'Original cell text: {cell.text}') + cell.text = self.reemplazar_variables(cell.text, solicitante) + logging.debug(f'Replaced cell text: {cell.text}') + + # Guardar el documento modificado en un BytesIO + buffer = BytesIO() + doc.save(buffer) + buffer.seek(0) + + # Preparar la respuesta para descargar el archivo DOCX + response = HttpResponse(buffer, content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document') + response['Content-Disposition'] = f'attachment; filename="formato_{formato_id}.docx"' + return response + + except Exception as e: + logging.error(f"Error occurred: {str(e)}", exc_info=True) + return HttpResponse(f"Error: {str(e)}", status=500) + + def reemplazar_variables(self, text, solicitante): + """ + Reemplaza las variables en el texto por los valores del solicitante. + """ + atributos = { + '{solicitante.nombre}': solicitante.nombre, + '{solicitante.ap_paterno}': solicitante.ap_paterno, + '{solicitante.ap_materno}': solicitante.ap_materno if solicitante.ap_materno else None, + '{solicitante.curp}': solicitante.curp, + '{solicitante.RFC}': solicitante.RFC, + '{solicitante.telefono}': solicitante.telefono, + '{solicitante.direccion}': solicitante.direccion, + '{solicitante.codigo_postal}': solicitante.codigo_postal, + '{solicitante.poblacion}': solicitante.poblacion, + '{solicitante.municipio}': solicitante.municipio.nombre if solicitante.municipio else None, + '{solicitante.datos_bancarios.nombre_banco}': solicitante.datos_bancarios.nombre_banco if solicitante.datos_bancarios else None, + '{solicitante.datos_bancarios.cuenta_bancaria}': solicitante.datos_bancarios.cuenta_bancaria if solicitante.datos_bancarios else None, + '{solicitante.datos_bancarios.clabe_bancaria}': solicitante.datos_bancarios.clabe_bancaria if solicitante.datos_bancarios else None, + } + + logging.debug(f'Atributos a reemplazar: {atributos}') + + for key, value in atributos.items(): + if value is None: + value = '' + text = text.replace(key, str(value)) + + + return text \ No newline at end of file diff --git a/cosiap_api/dynamic_tables/filtros.py b/cosiap_api/dynamic_tables/filtros.py deleted file mode 100644 index 8f4fe16..0000000 --- a/cosiap_api/dynamic_tables/filtros.py +++ /dev/null @@ -1,96 +0,0 @@ -from django.db import models -from django.db.models import Q - -CLASE_CAMPOS_BUSQUEDA = {'foreignKey': models.ForeignKey, 'manyToOne': models.ManyToOneRel, 'manyToMany': models.ManyToManyField, 'oneToOne': models.OneToOneRel} - -def get_related_fields(field, relatedFieldType, prefix='' ): - ''' - funcion recursiva para generar los nombres de los campos y los campos relacionados - - Argumentos: - - field (Campo relacionado del cual se obtendran sus campos) - - relatedFieldType (Tipo de campo permitido a buscar, si son de otro tipo seran ignorados) - - prefix (Prefijo que determina el nombre del campo en relacion con el modelo raiz) - ''' - - #el campo es un campo con un modelo relacionado valido, se llama a get_model_fields() para obtener sus campos - if isinstance(field, relatedFieldType): - related_model = field.related_model - related_fields = get_model_fields(related_model, relatedFieldType, prefix+field.name+'__') - return related_fields - #es un campo ignorado, se retorna una lista de campos vacia - elif field.__class__ in CLASE_CAMPOS_BUSQUEDA.values(): - return [] - #es un campo comun sin un modelo relacionado, se retorna tal cual - else: - return [prefix + field.name] - - -def get_model_fields(model, relatedFieldType, prefix='' ): - ''' - Funcion recursiva que llama a get_related_fields() para obtener los campos de un modelo. - - Argumentos: - - model (Modelo del cual se obtendran los nombres de los campos) - - relatedFieldType (Tipo de campo permitido a buscar, si son de otro tipo seran ignorados) - - prefix (Prefijo que determina el nombre del campo en relacion con el modelo raiz) - ''' - - fields = [] - #por cada campo del modelo se llama a get_related_fields() para obtener los nombres de los campos - #y o los nombres de los campos relacionados y se añade a la lista de campos - for field in model._meta.get_fields(): - fields.extend(get_related_fields(field, relatedFieldType, prefix)) - #se retornan todos los campos encontrados - return fields - - -def FiltradoEnCamposQuerySet(queryset, filter_query, matchExacto=False, relatedFieldType=CLASE_CAMPOS_BUSQUEDA['foreignKey']): - ''' - Funcion para filtrar un queryset en base a un string de palabras clave a buscar en sus campos - - - queryset (Queryset a la que se le aplicara el filtro) - - filter_query (String con todos los argumentos o terminos del filtro - pueden tener ) - - matchExacto (Determina el modo en que se manejan las coincidencias de los argumentos, si es true la coincidencia es verdadera si el match es exactamente la misma string, si es facil la coincidencia es verdadera si el term del argumento es una subscring del valor del campo) - - relatedFieldType (Tipo de campo con modelos relacionados que se tendran en cuenta, los demas se omitiran) - ''' - - search_terms = filter_query.split() #separamos la filter query en terms - model = queryset.model #obtenemos el modelo raiz del queryset - fields = get_model_fields(model, relatedFieldType) # obtenemos todos los nombres de los campos - - - q_objects = Q() - exclude_objects = Q() - - for term in search_terms: - term_query = Q() - is_exclude = term.startswith('-') # Verifica si el término comienza con '-' - is_or = term.startswith('~') # Verifica si el término comienza con '~' para OR - term = term[1:] if is_exclude or is_or else term - - for field in fields: - if ':' in term: #manejamos el tipo de argumento donde señala en que campo comparar - campo, valor = term.split(':', 1) - if campo == 'nombre' and ('nombre' == field or 'solicitante__nombre' in field): - term_query |= Q(**{f'{field}__icontains': valor}) - elif not matchExacto and campo != 'nombre' and campo in field: - term_query |= Q(**{f'{field}__icontains': valor}) - elif campo != 'nombre' and campo == field: - term_query |= Q(**{f'{field}__exact': valor}) - else: - term_query |= Q(**{f'{field}__icontains': term}) - - if is_exclude: # Agrega la condición al objeto de exclusión o al objeto de inclusión - exclude_objects &= term_query - elif is_or: # Realiza un OR con los términos de búsqueda - q_objects |= term_query - else: - q_objects &= term_query - - if search_terms and not q_objects: - queryset = model.objects.none() - else: - queryset = queryset.filter(q_objects).exclude(exclude_objects) - return queryset \ No newline at end of file diff --git a/cosiap_api/entrypoint.sh b/cosiap_api/entrypoint.sh index 914602a..c40aa8a 100644 --- a/cosiap_api/entrypoint.sh +++ b/cosiap_api/entrypoint.sh @@ -14,7 +14,6 @@ done >&2 echo "Data Base is up - executing command" - #Django commands >&2 echo "Ejecutando Migraciones" #python3 manage.py makemigrations --name diff --git a/cosiap_api/modalidades/models.py b/cosiap_api/modalidades/models.py index 0c774c7..a48d640 100644 --- a/cosiap_api/modalidades/models.py +++ b/cosiap_api/modalidades/models.py @@ -5,6 +5,7 @@ from django.utils import timezone import os from django.db.models.signals import pre_save from django.dispatch import receiver +from common.delete_old_files import borrar_archivo_viejo @@ -37,14 +38,9 @@ class Modalidad(models.Model): class Meta: ordering = ['nombre'] + + @receiver(pre_save, sender=Modalidad) -def borrar_imagen_vieja(sender, instance, **kwargs): - #si el objeto ya existe (es un update), removemos la imagen vieja - if instance.pk: - try: - old_instance = Modalidad.objects.get(pk=instance.pk) - if old_instance.imagen and old_instance.imagen != instance.imagen: - if os.path.isfile(old_instance.imagen.path): - os.remove(old_instance.imagen.path) - except Modalidad.DoesNotExist: - pass +def borrar_imagen_vieja_modalidad(sender, instance, **kwargs): + borrar_archivo_viejo(sender, instance, field_name='imagen', **kwargs) + diff --git a/cosiap_api/requirements.txt b/cosiap_api/requirements.txt index f282d0f..e2465c8 100644 --- a/cosiap_api/requirements.txt +++ b/cosiap_api/requirements.txt @@ -12,8 +12,12 @@ six>=1.16.0,<1.17.0 django-crontab>=0.7.1,<0.8.0 +python-docx==0.8.11 + channels[daphne]>=4.1.0,<4.2.0 channels-redis>=4.2.0,<4.3.0 gunicorn>=22.0.0,<22.1.0 -uvicorn[standard]>=0.30.1,<0.31.0 \ No newline at end of file +uvicorn[standard]>=0.30.1,<0.31.0 + + diff --git a/cosiap_api/solicitudes/views.py b/cosiap_api/solicitudes/views.py index db5e5c8..f871712 100644 --- a/cosiap_api/solicitudes/views.py +++ b/cosiap_api/solicitudes/views.py @@ -33,7 +33,8 @@ class SolicitudAPIView(DynamicTableAPIView): Clase para el manejo de la lista de solicitudes y la aplicación de sus filtros ''' - + permission_classes_list = [IsAuthenticated] + model_class = Solicitud model_name = 'Solicitud' columns = '__all__' diff --git a/cosiap_api/users/models.py b/cosiap_api/users/models.py index 86a9ddf..81c0be3 100644 --- a/cosiap_api/users/models.py +++ b/cosiap_api/users/models.py @@ -4,6 +4,9 @@ from django.core.validators import RegexValidator from django.contrib.auth.models import PermissionsMixin from django.core.exceptions import ValidationError from common.nombres_archivos import nombre_archivo_estado_cuenta, nombre_archivo_ine, nombre_archivo_sat +from django.db.models.signals import pre_save +from django.dispatch import receiver +from common.delete_old_files import borrar_archivo_viejo # modelo con la información de los estados de la república @@ -163,3 +166,16 @@ class Solicitante(Usuario): required_fields = ['ap_paterno', 'telefono', 'RFC', 'direccion', 'codigo_postal', 'municipio', 'poblacion', 'INE'] # verificamos que cada uno de los campos este lleno return all(getattr(self, field) for field in required_fields) + + +@receiver(pre_save, sender= DatosBancarios) +def borrar_constancia_sat(sender, instance, **kwargs): + borrar_archivo_viejo(sender, instance, field_name='doc_constancia_sat', **kwargs) + +@receiver(pre_save, sender= DatosBancarios) +def borrar_estado_cuenta(sender, instance, **kwargs): + borrar_archivo_viejo(sender, instance, field_name='doc_estado_cuenta', **kwargs) + +@receiver(pre_save, sender= Solicitante) +def borrar_INE(sender, instance, **kwargs): + borrar_archivo_viejo(sender, instance, field_name='INE', **kwargs) \ No newline at end of file -- GitLab