diff --git a/cosiap_api/common/models.py b/cosiap_api/common/models.py index 71a836239075aa6e6e4ecb700e9c42c95c022d91..63e0c5ad8dc44b9185759656880c8487de1ed569 100644 --- a/cosiap_api/common/models.py +++ b/cosiap_api/common/models.py @@ -1,3 +1,28 @@ from django.db import models # Create your models here. +class SingletonModel(models.Model): + ''' + Modelo Singleton para objetos que solo necesitan una instancia + ''' + + class Meta: + abstract = True + + def save(self, *args, **kwargs): + """ + Guarda el objeto en la base de datos y elimina los dema si existen + """ + self.__class__.objects.exclude(id=self.id).delete() + super(SingletonModel, self).save(*args, **kwargs) + + @classmethod + def get_object(cls): + """ + Carga el objeto de la base de datos. Si no existe ningun objeto + se crea uno nuevo sin guardar y lo devuelve. + """ + try: + return cls.objects.get() + except cls.DoesNotExist: + return cls() diff --git a/cosiap_api/common/nombres_archivos.py b/cosiap_api/common/nombres_archivos.py index 3fa5e3c57bb3352d232344d03c47a86141fb691b..73df4228a1f882fb5933b8256222ea05f78cc004 100644 --- a/cosiap_api/common/nombres_archivos.py +++ b/cosiap_api/common/nombres_archivos.py @@ -1,11 +1,17 @@ -import uuid +from uuid import uuid4 +from django.conf import settings import os # Función para generar nombre único de archivo -def generar_nombre_archivo(nombre_archivo, path): +def generar_nombre_archivo(nombre_archivo, path, protected=True): + ''' + Genera la ruta de un archivo de media con un nombre unico aleatorio. + ''' ext = nombre_archivo.split('.')[-1] - nombre_unico = f"{uuid.uuid4().hex}.{ext}" - return os.path.join(path, nombre_unico) + nombre_unico = f"{uuid4().hex}.{ext}" + if protected: + os.path.join('protected_uploads/', path) + return os.path.join(settings.MEDIA_ROOT, path, nombre_unico) # Funciones para nombre de archivo específico para cada campo de FileField def nombre_archivo_estado_cuenta(instance, filename): @@ -15,4 +21,16 @@ def nombre_archivo_sat(instance, filename): return generar_nombre_archivo(filename, 'constancia_sat_files/') def nombre_archivo_ine(instance, filename): - return generar_nombre_archivo(filename, 'INE_files/') \ No newline at end of file + return generar_nombre_archivo(filename, 'INE_files/') + +def nombre_archivo_minuta(instance, filename): + return generar_nombre_archivo(filename, 'minutas/',) + +def nombre_archivo_convenio(instance, filename): + return generar_nombre_archivo(filename, 'convenios/',) + +def nombre_archivo_modalidad(instance, filename): + return generar_nombre_archivo(filename, 'modalidades/', protected=False) + +def nombre_archivo_formato(instance, filename): + return generar_nombre_archivo(filename, 'formatos/', protected=False) \ No newline at end of file diff --git a/cosiap_api/common/validadores_campos.py b/cosiap_api/common/validadores_campos.py new file mode 100644 index 0000000000000000000000000000000000000000..e4bd9ec46251c05c54d6b4edba5b4d69cd32c81b --- /dev/null +++ b/cosiap_api/common/validadores_campos.py @@ -0,0 +1,18 @@ +import os +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + +def validador_pdf(value): + ''' + Validador para campos de archivo que verifica que su extencion sea un pdf + ''' + ext = os.path.splitext(value.name)[1] # Obtener la extensión del archivo + valid_extensions = ['.pdf'] # Lista de extensiones permitidas + if ext.lower() not in valid_extensions: + raise ValidationError(_('Sólo se permiten archivos en formato PDF.')) + +def validador_archivo_1MB(value): + '''Validador que valida que un archivo subido no sobrepase el tamaño de 1MB''' + filesize = value.size + if filesize > 1048576: # 1MB + raise ValidationError("El archivo es demasiado grande. El tamaño máximo permitido es 1MB.") \ No newline at end of file diff --git a/cosiap_api/cosiap_api/consumers.py b/cosiap_api/cosiap_api/consumers.py new file mode 100644 index 0000000000000000000000000000000000000000..b9648d5b2fdfec51632689fb9a54f9904cd69c19 --- /dev/null +++ b/cosiap_api/cosiap_api/consumers.py @@ -0,0 +1,27 @@ +import json +from channels.generic.websocket import AsyncWebsocketConsumer + +class NotificacionConsumer(AsyncWebsocketConsumer): + async def connect(self): + await self.accept() + # Asociar la conexión WebSocket con el usuario + self.user = self.scope["user"] + await self.channel_layer.group_add( + f"user_{self.user.id}", + self.channel_name + ) + + async def disconnect(self, close_code): + # Eliminar la asociación de la conexión WebSocket cuando se desconecta + await self.channel_layer.group_discard( + f"user_{self.user.id}", + self.channel_name + ) + + async def notificar_notificacion(self, event): + mensaje = event['mensaje'] + + # Envía el mensaje al cliente + await self.send(text_data=json.dumps({ + 'mensaje': mensaje + })) diff --git a/cosiap_api/cosiap_api/settings.py b/cosiap_api/cosiap_api/settings.py index aa384da052d74d282362ac62ab7a0556dc5e2c5f..3341e2c6470c2adc1d3056f4f29d1f97a06dbf37 100644 --- a/cosiap_api/cosiap_api/settings.py +++ b/cosiap_api/cosiap_api/settings.py @@ -148,7 +148,7 @@ EMAIL_USE_TLS = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/5.0/howto/static-files/ -STATIC_URL = 'static/' +STATIC_URL = '/static/' STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles/') STATICFILES_DIRS = ( @@ -181,7 +181,8 @@ REST_FRAMEWORK = { ), 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', 'DEFAULT_PERMISSION_CLASSES': [ - 'rest_framework.permissions.IsAuthenticated' + 'rest_framework.permissions.IsAuthenticated', + 'users.permisos.primer_login' #'rest_framework.permissions.AllowAny' ], } diff --git a/cosiap_api/cosiap_api/urls.py b/cosiap_api/cosiap_api/urls.py index d9305b702f8bfb6dff08ca1ed3ca4bc77b3a83cd..a08789bf85edb82787465ac2f4ebfeb8f7330d85 100644 --- a/cosiap_api/cosiap_api/urls.py +++ b/cosiap_api/cosiap_api/urls.py @@ -27,8 +27,9 @@ urlpatterns = [ path('api/modalidades/',include('modalidades.urls')), path('api/notificaciones/',include('notificaciones.urls')), path('api/solicitudes/',include('solicitudes.urls')), + path('api/dynamic-tables/',include('dynamic_tables.urls')), - # API Doc UI: + # API Doc UI: path('api/schema/', SpectacularAPIView.as_view(), name='schema'), path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), diff --git a/cosiap_api/cosiap_api/wsgi.py b/cosiap_api/cosiap_api/wsgi.py index 809e51c33dcb02f0ffca9b7e6c65c6c5beb3c190..05a5542dae05966e956f15768873dcaa74cdadbd 100644 --- a/cosiap_api/cosiap_api/wsgi.py +++ b/cosiap_api/cosiap_api/wsgi.py @@ -10,7 +10,27 @@ https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ import os from django.core.wsgi import get_wsgi_application +from django.core.asgi import get_asgi_application +from channels.routing import ProtocolTypeRouter, URLRouter +from django.core.asgi import get_asgi_application + +from channels.auth import AuthMiddlewareStack +from django.urls import re_path +from .consumers import NotificacionConsumer os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'cosiap_api.settings') -application = get_wsgi_application() +django_asgi_app = get_asgi_application() + +websocket_urlpatterns = [ + re_path(r'ws/notificaciones/$', NotificacionConsumer.as_asgi()), +] + +application = ProtocolTypeRouter({ + "http": django_asgi_app, + "websocket": AuthMiddlewareStack( + URLRouter( + websocket_urlpatterns + ) + ), +}) diff --git a/cosiap_api/dynamic_formats/migrations/0001_creacion_inicial_modulos_dynamic_formats__modalidades__solicitudes.py b/cosiap_api/dynamic_formats/migrations/0001_creacion_inicial_modulos_dynamic_formats__modalidades__solicitudes.py new file mode 100644 index 0000000000000000000000000000000000000000..6a5758361a1f4e837ba08b41a84d83db128a2f14 --- /dev/null +++ b/cosiap_api/dynamic_formats/migrations/0001_creacion_inicial_modulos_dynamic_formats__modalidades__solicitudes.py @@ -0,0 +1,28 @@ +# Generated by Django 5.0.6 on 2024-07-09 16:29 + +import common.nombres_archivos +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='DynamicFormat', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('nombre', models.CharField(max_length=255, verbose_name='Nombre')), + ('template', models.FileField(upload_to=common.nombres_archivos.nombre_archivo_formato, verbose_name='Plantilla')), + ], + options={ + 'verbose_name': 'Formato Dinámico', + 'verbose_name_plural': 'Formatos Dinámicos', + 'ordering': ['nombre'], + }, + ), + ] diff --git a/cosiap_api/dynamic_formats/models.py b/cosiap_api/dynamic_formats/models.py index 71a836239075aa6e6e4ecb700e9c42c95c022d91..222fae05bbf7f29f84baf0b3959491bb4086f9e8 100644 --- a/cosiap_api/dynamic_formats/models.py +++ b/cosiap_api/dynamic_formats/models.py @@ -1,3 +1,22 @@ from django.db import models +from common.nombres_archivos import nombre_archivo_formato -# Create your models here. +class DynamicFormat(models.Model): + ''' + Modelo que contiene la información de los formatos dinámicos. + + Campos: + - **nombre**: Nombre del formato. + - **template**: Archivo de plantilla asociado al formato. + ''' + + nombre = models.CharField(max_length=255, verbose_name="Nombre") + template = models.FileField(upload_to=nombre_archivo_formato, verbose_name="Plantilla") + + def __str__(self): + return self.nombre + + class Meta: + verbose_name = "Formato Dinámico" + verbose_name_plural = "Formatos Dinámicos" + ordering = ['nombre'] diff --git a/cosiap_api/dynamic_tables/DynamicTable.py b/cosiap_api/dynamic_tables/DynamicTable.py new file mode 100644 index 0000000000000000000000000000000000000000..250b935e32119c6e39f541080900def053647270 --- /dev/null +++ b/cosiap_api/dynamic_tables/DynamicTable.py @@ -0,0 +1,90 @@ +# Archivo con la lógica del manejo de las tablas dinámicas +# Autores: Adalberto Cerrillo Vázquez +# Versión: 1.0 + +from rest_framework import serializers +from django.apps import apps +from django.db.models import Q, Prefetch +from .models import DynamicTableReport + +class DynamicTable(serializers.ModelSerializer): + ''' + Clase equivalente a un serializer con la lógica del manejo de las tablas dinámicas + ''' + data = serializers.SerializerMethodField() + + class Meta: + model = DynamicTableReport + fields = '__all__' + + def get_data(self, obj): + ''' + Método que se encargará de recuperar los datos correspondientes del modelo enviado + + parámetros: + - obj: Configuración de reporte recibida, la cuál indica que datos se van a recuperar + ''' + + # 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 + else: + # Si no encontramos el modelo enviamos un error + raise serializers.ValidationError(f"Model {obj.model_name} not found.") + + 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 + + queryset = model.objects.all() + + # En este for, aplicamos los filtros envíados sobre el queryset + for key, value in filters.items(): + queryset = queryset.filter(**{key: value}) + + # 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) + + # Seleccionamos las columnas a incluir y las columnas a excluir + queryset = queryset.values(*[col for col in columns if col not in exclude_columns]) + + # 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)) + + # devolvemos el nuevo queryset con filtros y exclusiones realizadas + return list(queryset) + + + def create(self, validated_data): + ''' + Método que se encargará de guardar en la base de datos una nueva configuración para la tabla dinámica + + parámetros: + - 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( + 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) + ) + return instance diff --git a/cosiap_api/dynamic_tables/admin.py b/cosiap_api/dynamic_tables/admin.py index 8c38f3f3dad51e4585f3984282c2a4bec5349c1e..d163cdc2ed5d553e792f6e537b1121bc02f1af69 100644 --- a/cosiap_api/dynamic_tables/admin.py +++ b/cosiap_api/dynamic_tables/admin.py @@ -1,3 +1,4 @@ from django.contrib import admin +from .models import DynamicTableReport -# Register your models here. +admin.site.register(DynamicTableReport) \ No newline at end of file diff --git a/cosiap_api/dynamic_tables/migrations/0001_creacion_tabla_dynamictablereport.py b/cosiap_api/dynamic_tables/migrations/0001_creacion_tabla_dynamictablereport.py new file mode 100644 index 0000000000000000000000000000000000000000..8e6c9b139e02e43eae16ce7ad97df32ccf10bcda --- /dev/null +++ b/cosiap_api/dynamic_tables/migrations/0001_creacion_tabla_dynamictablereport.py @@ -0,0 +1,28 @@ +# Generated by Django 5.0.6 on 2024-07-10 16:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='DynamicTableReport', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('model_name', models.CharField(max_length=100)), + ('columns', models.JSONField()), + ('exclude_columns', models.JSONField(blank=True, null=True)), + ('search_query', models.CharField(blank=True, max_length=100, null=True)), + ('filters', models.JSONField(blank=True, null=True)), + ], + options={ + 'unique_together': {('model_name', 'columns', 'exclude_columns', 'search_query', 'filters')}, + }, + ), + ] diff --git a/cosiap_api/dynamic_tables/models.py b/cosiap_api/dynamic_tables/models.py index 71a836239075aa6e6e4ecb700e9c42c95c022d91..cbd4ac818da8a7f026b4d4b7e241e02e8e229a7e 100644 --- a/cosiap_api/dynamic_tables/models.py +++ b/cosiap_api/dynamic_tables/models.py @@ -1,3 +1,30 @@ from django.db import models -# Create your models here. +class DynamicTableReport(models.Model): + ''' + Clase para manejar y guardar las configuraciones de las tablas dinámicas y sus reportes generados + Columnas: + - model_name (Nombre del modelo que va a manejar) + - columns (Lista de columnas del modelo) + - exclude_columns (Lista de las columnas que no se van a incluir en el reporte) + - search_query (Cadena de búsqueda para la obtención de los datos del modelo y sus relaciones) + - filters (Filtros a aplicar recibidos desde el front) + ''' + model_name = models.CharField(max_length=100) + columns = models.JSONField() + # Campos opcionales + 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') + + + def __str__(self): + return 'Tabla Dinámica: ' + self.model_name + + + + diff --git a/cosiap_api/dynamic_tables/urls.py b/cosiap_api/dynamic_tables/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..c337d8687588acf093e43d3369957c09e096ffab --- /dev/null +++ b/cosiap_api/dynamic_tables/urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from .views import DynamicTableView + +app_name = 'dynamic-tables' +urlpatterns = [ + path('', DynamicTableView.as_view(), name='dynamic_table'), +] \ No newline at end of file diff --git a/cosiap_api/dynamic_tables/views.py b/cosiap_api/dynamic_tables/views.py index 91ea44a218fbd2f408430959283f0419c921093e..b73cc09cd8da512c356454dbfe6e095eef27727e 100644 --- a/cosiap_api/dynamic_tables/views.py +++ b/cosiap_api/dynamic_tables/views.py @@ -1,3 +1,19 @@ -from django.shortcuts import render +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from .DynamicTable import DynamicTable +from users.views import BasePermissionAPIView -# Create your views here. + +class DynamicTableView(BasePermissionAPIView): + ''' + Clase de APIView que heréda los permisos de la clase base + para el manejo de las tablas dinámicas y sus configuraciones + ''' + + def post(self, request): + serializer = DynamicTable(data=request.data) + if serializer.is_valid(): + instance = serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) \ No newline at end of file diff --git a/cosiap_api/entrypoint.sh b/cosiap_api/entrypoint.sh index 7f6735d621f03a35472d945b3691e1370447ee8b..914602aab7bcc9dca8f29022e89a359e0b954ce5 100644 --- a/cosiap_api/entrypoint.sh +++ b/cosiap_api/entrypoint.sh @@ -16,7 +16,8 @@ done #Django commands -#python3 manage.py makemigrations +>&2 echo "Ejecutando Migraciones" +#python3 manage.py makemigrations --name python3 manage.py migrate #python3 manage.py collectstatic --no-input diff --git a/cosiap_api/modalidades/migrations/0001_creacion_inicial_modulos_dynamic_formats__modalidades__solicitudes.py b/cosiap_api/modalidades/migrations/0001_creacion_inicial_modulos_dynamic_formats__modalidades__solicitudes.py new file mode 100644 index 0000000000000000000000000000000000000000..8728738a8a8f5028fc9ba283620d0a9ba50aba5f --- /dev/null +++ b/cosiap_api/modalidades/migrations/0001_creacion_inicial_modulos_dynamic_formats__modalidades__solicitudes.py @@ -0,0 +1,45 @@ +# Generated by Django 5.0.6 on 2024-07-09 16:29 + +import common.nombres_archivos +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Modalidad', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('nombre', models.CharField(max_length=255, verbose_name='Nombre')), + ('imagen', models.ImageField(upload_to=common.nombres_archivos.nombre_archivo_modalidad, verbose_name='Imagen')), + ('descripcion', models.TextField(verbose_name='Descripción')), + ('mostrar', models.BooleanField(default=True)), + ('archivado', models.BooleanField(default=False)), + ], + options={ + 'ordering': ['nombre'], + }, + ), + migrations.CreateModel( + name='MontoModalidad', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('monto', models.FloatField(default=0.0, verbose_name='Monto')), + ('fecha_inicio', models.DateTimeField(auto_now_add=True, verbose_name='Fecha de Inicio')), + ('fecha_fin', models.DateTimeField(blank=True, null=True, verbose_name='Fecha de Fin')), + ('modalidad', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='modalidades.modalidad', verbose_name='Modalidad')), + ], + options={ + 'verbose_name': 'Monto de Modalidad', + 'verbose_name_plural': 'Montos de Modalidades', + 'ordering': ['modalidad', '-fecha_inicio'], + }, + ), + ] diff --git a/cosiap_api/modalidades/models.py b/cosiap_api/modalidades/models.py index 71a836239075aa6e6e4ecb700e9c42c95c022d91..d43d3fd2e86f3c3d36f7e5f33e24209056b48b5b 100644 --- a/cosiap_api/modalidades/models.py +++ b/cosiap_api/modalidades/models.py @@ -1,3 +1,62 @@ from django.db import models +from common.validadores_campos import validador_pdf +from common.nombres_archivos import nombre_archivo_modalidad +from django.utils import timezone -# Create your models here. + +class Modalidad(models.Model): + ''' + Modelo que contiene la informacion de una modalidad. + + Campos: + - nombre: Nombre de la modalidad. Es un campo de texto con un máximo de 255 caracteres. + - imagen: Imagen asociada a la modalidad. + - descripcion: Descripción de la modalidad. Es un campo de texto. + - mostrar: Booleano que indica si la modalidad se debe mostrar o no. Por defecto es True. + - archivado: Booleano que indica si la modalidad está archivada. Por defecto es False. + - dynamic_form: Campo reservado para formularios dinámicos (actualmente no se usa). + ''' + nombre = models.CharField(max_length=255, verbose_name="Nombre", null=False) + imagen = models.ImageField(upload_to=nombre_archivo_modalidad, verbose_name="Imagen", null=False) + descripcion = models.TextField(verbose_name="Descripción", null=False) + mostrar = models.BooleanField(default=True) + archivado = models.BooleanField(default=False) + dynamic_form = None + + def __str__(self): + return f'{self.nombre} ({self.tipo})' + + class Meta: + ordering = ['nombre'] + +class MontoModalidad(models.Model): + ''' + Modelo que contiene los valores de los montos de las modalidades a través del tiempo. + + Campos: + - modalidad: Instancia de Modalidad a la que pertenece el monto. + - monto: Número flotante que indica el valor del monto de esa modalidad en ese periodo. Por defecto es 0.0. + - fecha_inicio: Fecha en la que comenzó a estar vigente el monto. Campo autogenerado. + - fecha_fin: Fecha en la que dejó de estar vigente el monto. Por defecto es None. Campo autogenerado: si existe algún MontoModalidad cuya fecha de fin esté sin definir, al momento de crearse un nuevo MontoModalidad para la misma Modalidad, el campo indefinido de ese MontoModalidad se define a la fecha actual. + ''' + modalidad = models.ForeignKey(Modalidad, on_delete=models.CASCADE, verbose_name="Modalidad") + monto = models.FloatField(default=0.0, verbose_name="Monto") + fecha_inicio = models.DateTimeField(auto_now_add=True, verbose_name="Fecha de Inicio") + fecha_fin = models.DateTimeField(null=True, blank=True, verbose_name="Fecha de Fin") + + def save(self, *args, **kwargs): + # Autogenerar la fecha de fin del MontoModalidad anterior + if not self.pk: # Solo aplica para nuevas instancias + ultimo_monto = MontoModalidad.objects.filter(modalidad=self.modalidad, fecha_fin__isnull=True).last() + if ultimo_monto: + ultimo_monto.fecha_fin = timezone.now() + ultimo_monto.save() + super(MontoModalidad, self.save(*args, **kwargs)) + + def __str__(self): + return f'{self.modalidad.nombre} - {self.monto}' + + class Meta: + verbose_name = "Monto de Modalidad" + verbose_name_plural = "Montos de Modalidades" + ordering = ['modalidad', '-fecha_inicio'] \ No newline at end of file diff --git a/cosiap_api/notificaciones/commands/delete_old_notifications.py b/cosiap_api/notificaciones/commands/delete_old_notifications.py new file mode 100644 index 0000000000000000000000000000000000000000..26b5b72d58f5e57dfb896c0c415c1968771d58f1 --- /dev/null +++ b/cosiap_api/notificaciones/commands/delete_old_notifications.py @@ -0,0 +1,16 @@ +from django.core.management.base import BaseCommand +from datetime import timedelta +from django.utils import timezone +from mensajes.models import Notificacion + +class Command(BaseCommand): + help = 'Elimina registros más antiguos de 3 meses' + + def handle(self, *args, **options): + borrar_notificaciones_viejas() + self.stdout.write(self.style.SUCCESS('Registros eliminados con éxito')) + +def borrar_notificaciones_viejas(): + print('Borrando notificaciones mas antiguas a 3 meses') + three_months_ago = timezone.now() - timedelta(days=90) + Notificacion.objects.filter(timestamp__lt=three_months_ago).delete() diff --git a/cosiap_api/notificaciones/migrations/0001_creacion_inicial_modulos_dynamic_formats__modalidades__solicitudes.py b/cosiap_api/notificaciones/migrations/0001_creacion_inicial_modulos_dynamic_formats__modalidades__solicitudes.py new file mode 100644 index 0000000000000000000000000000000000000000..67c313bdbdb620c4ae8c718582c8e82d5c844a7d --- /dev/null +++ b/cosiap_api/notificaciones/migrations/0001_creacion_inicial_modulos_dynamic_formats__modalidades__solicitudes.py @@ -0,0 +1,46 @@ +# Generated by Django 5.0.6 on 2024-07-09 16:29 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Notificacion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('leido', models.BooleanField(default=False)), + ('titulo', models.CharField(blank=True, default='Sistema de Apoyos COZCYT', max_length=255, null=True)), + ('mensaje', models.TextField()), + ('urlName', models.CharField(blank=True, max_length=255, null=True)), + ('urlArgs', models.JSONField(blank=True, null=True)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('usuario', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Usuario')), + ], + options={ + 'ordering': ['usuario', '-timestamp'], + }, + ), + migrations.CreateModel( + name='NotifInboxLastOpened', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('timestamp', models.DateTimeField(auto_now=True, verbose_name='Fecha y hora de última apertura')), + ('usuario', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Usuario')), + ], + options={ + 'verbose_name': 'Última apertura de bandeja de entrada', + 'verbose_name_plural': 'Últimas aperturas de bandeja de entrada', + 'ordering': ['-timestamp'], + }, + ), + ] diff --git a/cosiap_api/notificaciones/models.py b/cosiap_api/notificaciones/models.py index 71a836239075aa6e6e4ecb700e9c42c95c022d91..c8f173b94b751ac616787b6b1d8270e0c22b7185 100644 --- a/cosiap_api/notificaciones/models.py +++ b/cosiap_api/notificaciones/models.py @@ -1,3 +1,72 @@ from django.db import models +from django.db.models.signals import post_save +from django.dispatch import receiver -# Create your models here. +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer +from django.urls import reverse +from django.conf import settings + + +class NotifInboxLastOpened(models.Model): + ''' + Modelo que registra la última vez que un usuario abrió su bandeja de entrada de notificaciones. + + Campos: + - **usuario**: Usuario al que pertenece el registro. Debe ser único. + - **timestamp**: Fecha y hora en que el usuario abrió su bandeja de entrada por última vez. Se autogenera cuando se crea o actualiza el registro. + ''' + + usuario = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, unique=True, verbose_name="Usuario") + timestamp = models.DateTimeField(auto_now=True, verbose_name="Fecha y hora de última apertura") + + def __str__(self): + return f'Última apertura de {self.usuario.username} en {self.timestamp}' + + class Meta: + verbose_name = "Última apertura de bandeja de entrada" + verbose_name_plural = "Últimas aperturas de bandeja de entrada" + ordering = ['-timestamp'] + +class Notificacion(models.Model): + ''' + Modelo que contiene la información de las notificaciones. + + Campos: + - *usuario*: Usuario al que va dirigida la notificación. + - *leido*: Booleano que indica si el mensaje ya ha sido abierto. Por defecto es False. + - *titulo*: Título de la notificación. Por defecto es "Sistema de Apoyos COZCYT". Puede estar en blanco o ser nulo. + - *mensaje*: Mensaje de la notificación. + - *urlName*: Nombre de la URL asociada a la notificación. Puede estar en blanco o ser nulo. + - *urlArgs*: Argumentos de la URL en formato JSON. Puede estar en blanco o ser nulo. + - *timestamp*: Fecha y hora en que la notificación fue creada. Se autogenera cuando se crea la notificación. + ''' + TITULO_DEFAULT = 'Sistema de Apoyos COZCYT' + + usuario = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name="Usuario") + leido = models.BooleanField(default=False) + titulo = models.CharField(max_length=255, blank=True, null=True, default=TITULO_DEFAULT) + mensaje = models.TextField() + urlName = models.CharField(max_length=255, blank=True, null=True) + urlArgs = models.JSONField(blank=True, null=True) + timestamp = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['usuario', '-timestamp'] + + def __str__(self): + return f'Notificación de {self.usuario} - {self.timestamp}' + +@receiver(post_save, sender=Notificacion) +def notificar_nueva_notificacion(sender, instance, created, **kwargs): + if created: + if instance.usuario.is_authenticated: + # Lógica para enviar la notificación al usuario + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + f'user_{instance.usuario.id}', + { + 'type': 'notificar_notificacion', + 'mensaje': 'nuevaNotificacion', + } + ) \ No newline at end of file diff --git a/cosiap_api/solicitudes/migrations/0001_creacion_inicial_modulos_dynamic_formats__modalidades__solicitudes.py b/cosiap_api/solicitudes/migrations/0001_creacion_inicial_modulos_dynamic_formats__modalidades__solicitudes.py new file mode 100644 index 0000000000000000000000000000000000000000..e6b15acc4a1bf76cad58244b1caf03fec5e91f6d --- /dev/null +++ b/cosiap_api/solicitudes/migrations/0001_creacion_inicial_modulos_dynamic_formats__modalidades__solicitudes.py @@ -0,0 +1,40 @@ +# Generated by Django 5.0.6 on 2024-07-09 16:29 + +import common.nombres_archivos +import common.validadores_campos +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Convenio', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('archivo', models.FileField(upload_to=common.nombres_archivos.nombre_archivo_convenio, validators=[common.validadores_campos.validador_archivo_1MB, common.validadores_campos.validador_pdf], verbose_name='Archivo')), + ], + options={ + 'verbose_name': 'Minuta', + 'verbose_name_plural': 'Minutas', + 'ordering': ['pk'], + }, + ), + migrations.CreateModel( + name='Minuta', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('archivo', models.FileField(upload_to=common.nombres_archivos.nombre_archivo_minuta, validators=[common.validadores_campos.validador_archivo_1MB, common.validadores_campos.validador_pdf], verbose_name='Archivo')), + ], + options={ + 'verbose_name': 'Minuta', + 'verbose_name_plural': 'Minutas', + 'ordering': ['pk'], + }, + ), + ] diff --git a/cosiap_api/solicitudes/models.py b/cosiap_api/solicitudes/models.py index 71a836239075aa6e6e4ecb700e9c42c95c022d91..10c663715101cf71bcb7bd22a56acb8f8bc40914 100644 --- a/cosiap_api/solicitudes/models.py +++ b/cosiap_api/solicitudes/models.py @@ -1,3 +1,86 @@ from django.db import models +from common.nombres_archivos import nombre_archivo_minuta, nombre_archivo_convenio +from common.validadores_campos import validador_archivo_1MB, validador_pdf +from dynamic_formats.models import DynamicFormat -# Create your models here. +class Minuta(models.Model): + ''' + Modelo que contiene la información de las minutas. + + Campos: + - *archivo*: Archivo de la minuta. + + Métodos: + - *get_formato()*: Devuelve el formato común de todas las instancias de Minuta. + - *set_formato(value)*: Establece el formato común para todas las instancias de Minuta. + ''' + archivo = models.FileField(upload_to=nombre_archivo_minuta, validators=[validador_archivo_1MB, validador_pdf], verbose_name="Archivo") + # Atributo de clase común a todas las instancias + _formato = None + + @classmethod + def get_formato(cls): + ''' + Devuelve el formato común de todas las instancias de Minuta. + ''' + if cls._formato is None: + formato_minuta = DynamicFormat.objects.filter(nombre="formato_minuta_defult").first() + cls._formato = formato_minuta + return cls._formato + + @classmethod + def set_formato(cls, value): + '''Establece el formato común para todas las instancias de Minuta. + Args: + - value: El nuevo valor del formato. + ''' + cls._formato = value + + def __str__(self): + return f'Minuta {self.pk}' + + class Meta: + verbose_name = "Minuta" + verbose_name_plural = "Minutas" + ordering = ['pk'] + +class Convenio(models.Model): + ''' + Modelo que contiene la información de los Convenios. + + Campos: + - *archivo*: Archivo del convenio. + + Métodos: + - *get_formato()*: Devuelve el formato común de todas las instancias de Minuta. + - *set_formato(value)*: Establece el formato común para todas las instancias de Minuta. + ''' + archivo = models.FileField(upload_to=nombre_archivo_convenio, validators=[validador_archivo_1MB, validador_pdf], verbose_name="Archivo") + # Atributo de clase común a todas las instancias + _formato = None + + @classmethod + def get_formato(cls): + ''' + Devuelve el formato común de todas las instancias de Convenio. + ''' + if cls._formato is None: + formato_convenio = DynamicFormat.objects.filter(nombre="formato_convenio_defult").first() + cls._formato = formato_convenio + return cls._formato + + @classmethod + def set_formato(cls, value): + '''Establece el formato común para todas las instancias de Minuta. + Args: + - value: El nuevo valor del formato. + ''' + cls._formato = value + + def __str__(self): + return f'Minuta {self.pk}' + + class Meta: + verbose_name = "Minuta" + verbose_name_plural = "Minutas" + ordering = ['pk'] \ No newline at end of file diff --git a/cosiap_api/solicitudes/signals.py b/cosiap_api/solicitudes/signals.py new file mode 100644 index 0000000000000000000000000000000000000000..a9c4e435199a60f2ea8f1469aee9452c74dafed8 --- /dev/null +++ b/cosiap_api/solicitudes/signals.py @@ -0,0 +1,4 @@ +from django.dispatch import receiver +from dynamic_formats.models import DynamicFormat +from solicitudes.models import Minuta, Convenio + diff --git a/cosiap_api/users/admin.py b/cosiap_api/users/admin.py index a218f14ee66f39a5e8310c0fe5ecd980113f621c..7a5bef604d1bc87deebe8e716e39ded7b71b05b8 100644 --- a/cosiap_api/users/admin.py +++ b/cosiap_api/users/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin -from users.models import Usuario, Solicitante, Municipio, Estado +from users.models import Usuario, Solicitante, Municipio, Estado, DatosBancarios # Register your models here. @@ -33,4 +33,5 @@ class UserAdmin(UserAdmin): admin.site.register(Usuario, UserAdmin) admin.site.register(Solicitante) admin.site.register(Estado) -admin.site.register(Municipio) \ No newline at end of file +admin.site.register(Municipio) +admin.site.register(DatosBancarios) \ No newline at end of file diff --git a/cosiap_api/users/admin_views.py b/cosiap_api/users/admin_views.py index 95ada56f93abeb39cf7135175ef8eaa1a1a59d98..5a93d66a8551d93e80aa12ce51d86912b48d9219 100644 --- a/cosiap_api/users/admin_views.py +++ b/cosiap_api/users/admin_views.py @@ -13,7 +13,7 @@ from .tokens import account_activation_token from django.utils.encoding import force_bytes # clase de APIView con el método post para la creación de el administrador -class administrador(APIView): +class AdminAPIView(APIView): # permitimos que unicamente un administrador pueda crear otras cuentas de admin permission_classes = [es_admin] diff --git a/cosiap_api/users/permisos.py b/cosiap_api/users/permisos.py index 6382d666f53b91037fd8c9f4ddc803d7cad95dc9..b49e885314eb442eec18e1696504d2bbee5c1b0f 100644 --- a/cosiap_api/users/permisos.py +++ b/cosiap_api/users/permisos.py @@ -9,6 +9,7 @@ class es_admin(permissions.BasePermission): # Clase para verificar que el usuario que va a ingresar tenga sus datos completos class primer_login(permissions.BasePermission): + message = 'El usuario requiere completar su información.' # método para determinar si se requiere o no un primer login def has_permission(self, request, view): # obtenemos al usuario de la request diff --git a/cosiap_api/users/urls.py b/cosiap_api/users/urls.py index 0e773a5e94c95947e77559d88df0f8dc217d2e79..d58e6e480d968bfae1dd3e533da8a9d83d27be2e 100644 --- a/cosiap_api/users/urls.py +++ b/cosiap_api/users/urls.py @@ -2,7 +2,7 @@ from . import views from django.urls import path from django.contrib.auth import views as auth_views from .views import CustomTokenObtainPairView, CustomTokenRefreshView -from .admin_views import administrador +from .admin_views import AdminAPIView app_name = 'users' @@ -10,12 +10,12 @@ urlpatterns = [ path('token/', CustomTokenObtainPairView.as_view(), name='token_obtain'), path('token/refresh/', CustomTokenRefreshView.as_view(), name='token_refresh'), - path('', views.Usuario.as_view(), name = 'usuario_list_create'), - path('/', views.Usuario.as_view(), name = 'usuario_get_delete'), - path('solicitantes/', views.Solicitante.as_view(), name = 'solicitante_list_create'), - path('solicitantes/', views.Solicitante.as_view(), name = 'solicitante_get'), + path('', views.UsuarioAPIView.as_view(), name = 'usuario_list_create'), + path('/', views.UsuarioAPIView.as_view(), name = 'usuario_get_delete'), + path('solicitantes/', views.SolicitanteAPIView.as_view(), name = 'solicitante_list_create'), + path('solicitantes/', views.SolicitanteAPIView.as_view(), name = 'solicitante_get'), path('verificar-correo///', views.VerificarCorreo.as_view(), name='verificar_correo'), path('restablecer-password/', views.ResetPassword.as_view(), name='reset_password'), path('nueva-password///', views.NuevaPassword.as_view(), name='nueva_password'), - path('administradores/', administrador.as_view() , name = 'administrador_list_create'), + path('administradores/', AdminAPIView.as_view() , name = 'administrador_list_create'), ] \ No newline at end of file diff --git a/cosiap_api/users/views.py b/cosiap_api/users/views.py index 9633fb0cc19aacdf72c097d149650c8dc1ffc862..acbdc43514c58cc5ba0461936e85781f6da18e0d 100644 --- a/cosiap_api/users/views.py +++ b/cosiap_api/users/views.py @@ -113,7 +113,7 @@ class BasePermissionAPIView(APIView): # Funcionalidad para crear un usuario en el sistema, ver sus datos o eliminarlo -class Usuario(BasePermissionAPIView): +class UsuarioAPIView(BasePermissionAPIView): """ Clase Usuario para manejar las solicitudes de los usuarios básicos @@ -201,7 +201,7 @@ class Usuario(BasePermissionAPIView): return Response({"message": {'success': 'Eliminación exitosa'}}, status=status.HTTP_204_NO_CONTENT) -class Solicitante(BasePermissionAPIView): +class SolicitanteAPIView(BasePermissionAPIView): """ Clase Solicitante