diff --git a/cosiap_api/.dockerignore b/cosiap_api/.dockerignore index e0cc78f06b30360d46f13d386fa3f27794c54a36..f1a90775eb4bcb612263790386f1b3f00b2d666c 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 0000000000000000000000000000000000000000..9d94234c2c83d83d49a84f0cf1e024fc433c9742 --- /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 b881abe2bfd1a89f5af308d350d2c503fe0b1210..3161d3650acb7ee92f4a28ab4f39dcff10a14085 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 222fae05bbf7f29f84baf0b3959491bb4086f9e8..7e1cca4e1ea3ccd9fa5a4b7e228994abdf72a447 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 0000000000000000000000000000000000000000..11eb9b0c6975d8b3b09cf7b453d968ceaf7f8671 --- /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 7ce503c2dd97ba78597f6ff6e4393132753573f6..bdd03866192623baab9a9d15952cc3836586de17 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 0000000000000000000000000000000000000000..5c7db51d5df5e8a157b57d32d99f297437d29f5b --- /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 91ea44a218fbd2f408430959283f0419c921093e..c515eea9ae2eceecd43d65bf1f24781baa542b32 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 8f4fe1626cd947e7d4729583d5f41f498b6df9fa..0000000000000000000000000000000000000000 --- 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 914602aab7bcc9dca8f29022e89a359e0b954ce5..c40aa8a84b1688e48539cfbd71e872b011df9d13 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 0c774c7d5b6b72bc090fa850afe3feb10ad80133..a48d6404637fcdbd9ab14c152e41d573021124d0 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 f282d0fcb04d5746e8c2c7bf456056c11ac5ba22..e2465c8a489ef90b4325b133564dcc2ffac5effa 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 db5e5c83941e2a6c562a059cc979679e28667c4c..f87171255f6424dd88698802273ab25211817ab1 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 86a9ddfea5b274386072003c02028a3614e64aca..81c0be3aa441335caf7c8f74518fa66cd58c995c 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