diff --git a/cosiap_api/cosiap_api/settings.py b/cosiap_api/cosiap_api/settings.py index f08a3c941f01ed24f6fb9871abc6b0f501d2427b..ea7b0b0819ef9346d07278f4062654f61c2d357e 100644 --- a/cosiap_api/cosiap_api/settings.py +++ b/cosiap_api/cosiap_api/settings.py @@ -14,6 +14,7 @@ import os # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +BASE_URL = 'http://localhost:8000/' # Cambiar esto por la URL base de la aplicación en producción # Quick-start development settings - unsuitable for production @@ -44,7 +45,6 @@ INSTALLED_APPS = [ 'rest_framework_simplejwt', 'drf_spectacular', 'corsheaders', - 'users', ] @@ -185,3 +185,4 @@ SPECTACULAR_SETTINGS = { #CORS_ALLOWED_ORIGINS = [ # "http://localhost:5173" #] + diff --git a/cosiap_api/cosiap_api/urls.py b/cosiap_api/cosiap_api/urls.py index e4f406e06d526745ea81282442ac4f9560fb6871..2ac902b84738cde32269c42061e7da9b4a446561 100644 --- a/cosiap_api/cosiap_api/urls.py +++ b/cosiap_api/cosiap_api/urls.py @@ -15,6 +15,8 @@ Including another URLconf 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.contrib import admin +from django.conf import settings +from django.conf.urls.static import static from django.urls import path, include from rest_framework_simplejwt.views import ( TokenObtainPairView, @@ -32,4 +34,4 @@ urlpatterns = [ # API Doc UI: 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'), -] \ No newline at end of file +] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) \ No newline at end of file diff --git a/cosiap_api/users/admin.py b/cosiap_api/users/admin.py index 2d41865158cac8450b9fc1f11fc74295d869ebe3..d74e9669bdf5cab4faf4520daf40f2726e5c2c6d 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 +from users.models import Usuario, Solicitante # Register your models here. @@ -29,5 +29,6 @@ class UserAdmin(UserAdmin): ordering = ['id'] list_filter = ('is_staff', 'is_superuser', 'is_active') - -admin.site.register(Usuario, UserAdmin) \ No newline at end of file + +admin.site.register(Usuario, UserAdmin) +admin.site.register(Solicitante) \ No newline at end of file diff --git a/cosiap_api/users/migrations/0002_campo_toke_usuario.py b/cosiap_api/users/migrations/0002_campo_toke_usuario.py new file mode 100644 index 0000000000000000000000000000000000000000..9882691bd16e9d87e12b5024abd9b3ead15b775d --- /dev/null +++ b/cosiap_api/users/migrations/0002_campo_toke_usuario.py @@ -0,0 +1,24 @@ +# Generated by Django 5.0.6 on 2024-06-27 20:34 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='usuario', + name='token_verificacion', + field=models.UUIDField(default=uuid.uuid4, editable=False), + ), + migrations.AlterField( + model_name='usuario', + name='is_active', + field=models.BooleanField(default=False), + ), + ] diff --git a/cosiap_api/users/migrations/0003_eliminacion_campo_token_usuario.py b/cosiap_api/users/migrations/0003_eliminacion_campo_token_usuario.py new file mode 100644 index 0000000000000000000000000000000000000000..fa82d61320f797f60461e1da03f57e05ac82fbd4 --- /dev/null +++ b/cosiap_api/users/migrations/0003_eliminacion_campo_token_usuario.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0.6 on 2024-06-28 18:28 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_campo_toke_usuario'), + ] + + operations = [ + migrations.RemoveField( + model_name='usuario', + name='token_verificacion', + ), + ] diff --git a/cosiap_api/users/migrations/0004_creacion_modelo_solicitante.py b/cosiap_api/users/migrations/0004_creacion_modelo_solicitante.py new file mode 100644 index 0000000000000000000000000000000000000000..4080e62b5706027a64b6872671106a38af14abac --- /dev/null +++ b/cosiap_api/users/migrations/0004_creacion_modelo_solicitante.py @@ -0,0 +1,34 @@ +# Generated by Django 5.0.6 on 2024-06-28 20:07 + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0003_eliminacion_campo_token_usuario'), + ] + + operations = [ + migrations.CreateModel( + name='Solicitante', + fields=[ + ('usuario_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)), + ('ap_paterno', models.CharField(max_length=50, verbose_name='Apellido Paterno')), + ('ap_materno', models.CharField(blank=True, max_length=50, null=True, verbose_name='Apellido Materno')), + ('telefono', models.CharField(max_length=10, verbose_name='Teléfono')), + ('RFC', models.CharField(max_length=13, unique=True, validators=[django.core.validators.RegexValidator('^[A-Z&Ñ]{4}\\d{6}[A-Z0-9]{3}$', 'Debe ser un RFC válido.')], verbose_name='RFC')), + ('direccion', models.CharField(max_length=255, verbose_name='Dirección')), + ('codigo_postal', models.CharField(max_length=5, verbose_name='Código Postal')), + ('poblacion', models.CharField(max_length=255, verbose_name='Población')), + ('INE', models.FileField(blank=True, null=True, upload_to='ine_files/', verbose_name='INE')), + ], + options={ + 'abstract': False, + }, + bases=('users.usuario',), + ), + ] diff --git a/cosiap_api/users/models.py b/cosiap_api/users/models.py index 3d85f4eccce504b8057068ce13afe738f3b94043..d90e7c6407c40953058e7d8c5f8863e9d59dd21d 100644 --- a/cosiap_api/users/models.py +++ b/cosiap_api/users/models.py @@ -3,49 +3,38 @@ from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager from django.core.validators import RegexValidator from django.contrib.auth.models import PermissionsMixin from django.core.exceptions import ValidationError - -# Create your models here. - +import uuid class UsuarioManager(BaseUserManager): - def create_user(self, email, curp, nombre, password=None, is_admin=False, is_staff=False, is_active=True): + def create_user(self, email, curp, nombre, password=None, is_admin=False, is_staff=False, is_active=False): user = self.model( - email=self.normalize_email(email) + email=self.normalize_email(email), + curp=curp, + nombre=nombre, + is_staff=is_staff, + is_active=is_active ) - user.curp = curp - user.nombre = nombre user.set_password(password) - user.is_superuser = is_admin - user.is_staff = is_staff - user.is_active = is_active user.save(using=self._db) return user def create_superuser(self, email, curp, nombre, password=None, **extra_fields): user = self.model( - email=self.normalize_email(email) + email=self.normalize_email(email), + curp=curp, + nombre=nombre, + is_staff=True, + is_superuser=True, + is_active=True, + **extra_fields ) - user.curp = curp - user.nombre = nombre user.set_password(password) - user.is_superuser = True - user.is_staff = True - user.is_active = True user.save(using=self._db) return user - class Usuario(AbstractBaseUser, PermissionsMixin): - """ Clase bas de los usuarios del sistema. + """ Clase base de los usuarios del sistema. """ - Attributes: - nombre (str): Nombre del usuario. - curp (str): CURP. - email (str): Correo electronico. - is_staff (bool): Es parte del staff. - is_active (bool): Esta activo y puede ingresar al sistema. - """ - CURP_REGEX = r'^[A-Z]{1}[AEIOU]{1}[A-Z]{2}[0-9]{2}(0[1-9]|1[0-2])(0[1-9]|1[0-9]|2[0-9]|3[0-1])[HM]{1}(AS|BC|BS|CC|CS|CH|CL|CM|DF|DG|GT|GR|HG|JC|MC|MN|MS|NT|NL|OC|PL|QT|QR|SP|SL|SR|TC|TS|TL|VZ|YN|ZS|NE)[B-DF-HJ-NP-TV-Z]{3}[0-9A-Z]{1}[0-9]{1}$' nombre = models.CharField( @@ -53,23 +42,45 @@ class Usuario(AbstractBaseUser, PermissionsMixin): curp = models.CharField( verbose_name="CURP", max_length=18, validators=[RegexValidator(CURP_REGEX,'Debe ser un CURP valido.')], - blank=False, null=False, unique=True) + unique=True) email = models.EmailField(verbose_name="E-mail", blank=False, null=False, unique=True) is_staff = models.BooleanField(default=False) - is_active = models.BooleanField(default=True) - + is_active = models.BooleanField(default=False) # Cambiamos este campo a inactivo por defecto + EMAIL_FIELD = 'email' USERNAME_FIELD = 'curp' REQUIRED_FIELDS = ['nombre','email'] objects = UsuarioManager() def __str__(self): - str = "" - if self.is_superuser: - str = str + "SuperUser " - if self.is_staff: - str = str + "Staff " - return str + self.curp + return self.curp class Meta: ordering = ['-is_superuser', 'id' ] + + +# clase Solicitante que hereda de Usuario +class Solicitante(Usuario): + # validador para el formato del RFC + RFC_REGEX = r'^[A-Z&Ñ]{4}\d{6}[A-Z0-9]{3}$' + + # campos para los apellidos + ap_paterno = models.CharField(verbose_name='Apellido Paterno',max_length=50) + # campo de apellido materno es opcional + ap_materno = models.CharField(verbose_name='Apellido Materno', max_length=50, null=True, blank=True) + # campo para telefono, longitud maxima de 10 + telefono = models.CharField(verbose_name='Teléfono', max_length=10) + # campo para el RFC, longitud de 13 + RFC = models.CharField(verbose_name='RFC', max_length=13, validators=[RegexValidator(RFC_REGEX,'Debe ser un RFC válido.')], unique=True) + # campo para la dirección + direccion = models.CharField(verbose_name='Dirección', max_length=255) + # campo para el código postal, longitud 5 + codigo_postal = models.CharField(verbose_name='Código Postal', max_length=5) + # campo para la poblacion + poblacion = models.CharField(verbose_name='Población', max_length=255) + # campo para la identificación oficial + INE = models.FileField(verbose_name='INE', upload_to='ine_files/', null=True, blank=True) + + + + diff --git a/cosiap_api/users/serializers.py b/cosiap_api/users/serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..e2a7d6d3ed8c9b25dd5c75f2e34adf576e419fb1 --- /dev/null +++ b/cosiap_api/users/serializers.py @@ -0,0 +1,43 @@ +# Archivo con los serializers necesarios para la gestión de los usuarios +# Autores: Adalberto Cerrillo Vázquez, +# Versión: 1.0 + +from rest_framework import serializers +from .models import Usuario + +# serializer para el usuario solicitante +class usuario_serializer(serializers.ModelSerializer): + # Creamos un campo de confirmación de la contraseña del solicitante + confirmar_password = serializers.CharField(write_only=True) + + class Meta: + # Indicamos el modelo + model = Usuario + # Indicamos los campos que deseamos incluir + fields = ['nombre', 'curp', 'email', 'password', 'confirmar_password'] + # Indicamos algún argumento extra necesario + extra_kwargs = { + 'password': {'write_only': True} + } + + # validamos que ambas contraseñas enviadas coincidan + def validate(self, data): + # realizamos la comparación de las contraseñas + if data['password'] != data['confirmar_password']: + # Si las contraseñas no coinciden se muestra un error + raise serializers.ValidationError("Atención: Las contraseñas no coinciden.") + return data + + # Función para crear el usuario, usando los datos ya validados + def create(self, validated_data): + # Creamos el usuario solicitante por medio de la funcion create_user del modelo + user = Usuario.objects.create_user( + email=validated_data['email'], + curp=validated_data['curp'], + nombre=validated_data['nombre'], + password=validated_data['password'], + is_admin=False, + is_staff=False + ) + # Retornamos el usuario creado + return user \ No newline at end of file diff --git a/cosiap_api/users/tests.py b/cosiap_api/users/tests.py index 7ce503c2dd97ba78597f6ff6e4393132753573f6..0266776c4ab1a3e64c9074fb8f9b3519b98ee1ae 100644 --- a/cosiap_api/users/tests.py +++ b/cosiap_api/users/tests.py @@ -1,3 +1,3 @@ -from django.test import TestCase - -# Create your tests here. +# Archivo con las funcionalidades de prueba para la gestión de usuarios +# Autor: Adalberto Cerrillo Vázquez +# Versión: 1.0 \ No newline at end of file diff --git a/cosiap_api/users/tokens.py b/cosiap_api/users/tokens.py new file mode 100644 index 0000000000000000000000000000000000000000..661419fb0825f70deeed52f3524793c5fde15b11 --- /dev/null +++ b/cosiap_api/users/tokens.py @@ -0,0 +1,10 @@ +from django.contrib.auth.tokens import PasswordResetTokenGenerator +import six + +class AccountActivationTokenGenerator(PasswordResetTokenGenerator): + def _make_hash_value(self, user, timestamp): + return ( + six.text_type(user.pk) + six.text_type(timestamp) + six.text_type(user.is_active) + ) + +account_activation_token = AccountActivationTokenGenerator() \ No newline at end of file diff --git a/cosiap_api/users/urls.py b/cosiap_api/users/urls.py index 93a248404969a9081a813a47a6328aa6b71d24a6..64b4312cdd551511cd48e5416b7c4a68d6a96367 100644 --- a/cosiap_api/users/urls.py +++ b/cosiap_api/users/urls.py @@ -18,5 +18,8 @@ from django.urls import path from django.contrib.auth import views as auth_views app_name = 'users' -urlpatterns = [ +urlpatterns = [ + path('usuarios/', views.usuario.as_view(), name = 'usuario-lista-crear'), + path('usuarios//', views.usuario.as_view(), name = 'ver-eliminar-usuario'), + path('usuarios/verificar///', views.verificar_token, name='verificar_token'), ] \ No newline at end of file diff --git a/cosiap_api/users/views.py b/cosiap_api/users/views.py index 91ea44a218fbd2f408430959283f0419c921093e..cbac2c3f53cf7299ae7aa632e7187cf40456a3d3 100644 --- a/cosiap_api/users/views.py +++ b/cosiap_api/users/views.py @@ -1,3 +1,171 @@ -from django.shortcuts import render +# Archivo con la funcionalidad necesaria para la gestión de usuarios en la API +# Autores: Adalberto Cerrillo Vázquez, +# Versión: 1.0 -# Create your views here. +from django.shortcuts import render, get_object_or_404, redirect +from rest_framework import generics, status, permissions +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import AllowAny, IsAuthenticated +from .models import Usuario +from django.contrib import messages +from .serializers import usuario_serializer +from django.contrib.auth import authenticate +from django.core.mail import send_mail +from django.conf import settings +from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode +from django.utils.encoding import force_bytes, force_str +from django.contrib.auth.tokens import PasswordResetTokenGenerator +from .tokens import account_activation_token + +# Funcionalidad para verificar que el usuario que realiza la eliminación sea un admin +class es_admin(permissions.BasePermission): + # método para determinar que un usuario tiene permisos + def has_permission( self, request, view): + return request.user and request.user.is_superuser + + +# Funcionalidad para crear un usuario en el sistema, ver sus datos o eliminarlo +class usuario(APIView): + # permitimos que cualquier persona que haga una solicitud pueda crear un usuario + permission_classes_create = [AllowAny] + # permitimos solo a administradores eliminar un usuario + permission_classes_delete = [es_admin] + # permitimos solo administradores vean la lista de usuarios + permission_classes_list = [es_admin] + + # función para verificar los permisos de la solicitud + def check_permissions( self, request ): + # verificamos si la solicitud fue hecha mediante delete + if request.method == 'DELETE': + # recorremos la lista de permisos de eliminación predefinada + for permission in self.permission_classes_delete: + # si el usuario no cuenta con permisos + if not permission().has_permission(request, self): + # denegamos los permisos para la eliminación + self.permission_denied( + request, + message=getattr(permission, 'message', None) + ) + elif request.method == 'GET': + # recorremos la lista de permisos de visualización predefinada + for permission in self.permission_classes_list: + # si el usuario no cuenta con permisos + if not permission().has_permission(request, self): + # denegamos los permisos para la eliminación + self.permission_denied( + request, + message=getattr(permission, 'message', None) + ) + + # Función para la obtención de la lista de usuarios + def get( self, request, *args, **kwargs ): + # si se quiere observar solo un usuario + if 'pk' in kwargs: + self.check_object_permissions(request, request.user) + # obtenemos la instancia del usario + instance = get_object_or_404(Usuario, pk=kwargs['pk']) + self.check_object_permissions(request, instance) + # indicamos el serializer a utilizar y enviamos la instancia + serializer = usuario_serializer(instance) + # devolvemos los datos del usuario + return Response(serializer.data) + # si se desea ver la lista completa + else: + # indicamos el query set de todos los usuarios + queryset = Usuario.objects.all() + # indicamos el serializer a utilizar y enviamos el queryset + serializer = usuario_serializer(queryset, many=True) + # retornamos la lista de usuarios + return Response(serializer.data) + + # Función para la creación del usuario + def post( self, request, *args, **kwargs ): + # obtenemos el email y la curp directamente del request + email = request.data.get('email') + curp = request.data.get('curp') + # filtramos en la base de datos para obtener al usuario con la misma curp + curp_exist = Usuario.objects.filter(curp=curp, is_active=False) + # filtramos en la base de datos para obtener al usuario con el mismo email + email_exist = Usuario.objects.filter(email=email, is_active=False).exclude(curp=curp) + # si la curp existe + if curp_exist: + # extraemos el usuario asociado a ella + curp_exist = Usuario.objects.get(curp=curp) + else: + # en caso de que no exista dejamos curp_exist como None para que el serializer inicie una instancia vacia + curp_exist = None + # si el email existe + if email_exist: + # eliminamos el usuario asociado a el + email_exist = Usuario.objects.get(email=email).delete() + # sobreescribimos el serializer enviando la instancia encontrada + serializer = usuario_serializer(data=request.data, instance=curp_exist) + # verificamos los datos enviados + if serializer.is_valid(): + # creamos la instancia del nuevo usuario como inactivo + usuario_nuevo = serializer.save(is_active = False) + # extraemos el id del usuario y lo decodificacmos a base64 + uid = urlsafe_base64_encode(force_bytes(usuario_nuevo.pk)) + # creamos el token asociado al usuario + token = account_activation_token.make_token(usuario_nuevo) + # enviamos el correo de verificación con el token + enviar_correo_verificacion(usuario_nuevo.email, uid, token) + # retornamos la respuesta de creación + return Response(serializer.data, status=status.HTTP_201_CREATED) + # en caso de que los datos sean incorrectos, retornamos un error. + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + + # Función para la eliminación del usuario + def delete( self, request, *args, **kwargs ): + # revisamos que el usuario que realizó el request tenga permisos + self.check_object_permissions(request, request.user) + # obtenemos la intancia del usuario a eliminar + instance = get_object_or_404(Usuario, pk=kwargs['pk']) + self.check_object_permissions(request, instance) + # eliminamos la intancia + instance.delete() + # devolvemos una respuesta sin contenido confirmando la eliminación + return Response(status=status.HTTP_204_NO_CONTENT) + + +# Función para enviar el correo de confirmación +def enviar_correo_verificacion(email, uid, token): + # tema o título del correo + subject = 'Verificación de la cuenta' + # cuerpo del correo con el enlace de verificación, que incluye el token y el id del usuario + message = f'Para activar tu cuenta, haz clic en el siguiente enlace:\n\n{settings.BASE_URL}usuarios/verificar/{uid}/{token}/' + # extraemos el correo remitente desde la configuración del settings + from_email = settings.EMAIL_HOST_USER + # indicamos el correo o correos destinatarios + recipient_list = [email] + # enviamos el email de verificación + send_mail(subject, message, from_email, recipient_list) + +# Función para verificar la cuenta del usuario +def verificar_token(request, uidb64, token): + # Si el usuario asociado al token existe + try: + # extraemos el id del usuario presente en el token + uid = force_str(urlsafe_base64_decode(uidb64)) + # extraemos el usuario de la base de datos + usuario = Usuario.objects.get(pk=uid) + except (TypeError, ValueError, OverflowError, Usuario.DoesNotExist): + # si el usuario no existe lo seteamos a None + usuario = None + # Utilizamos la función check_token() para verificar que el token sea correcto y pertenezca al usuario + if usuario is not None and account_activation_token.check_token(usuario, token): + # activamos la cuenta del usuario + usuario.is_active = True + # guardamos los cambios + usuario.save() + # si es necesario, enviamos un mensaje de exito + messages.success(request, 'Tu cuenta ha sido verificada correctamente. Ahora puedes iniciar sesión.') + # NOTA ADAL: Esta redirección se cambiara según las necesidades futuras + return redirect('users:usuario-lista-crear') + else: + # si algo sale mal, indicamos simplemente que el token no es válido + messages.error(request, 'El enlace de verificación no es válido o ha expirado.') + # NOTA ADAL: Esta redirección se cambiara según las necesidades futuras + return redirect('users:usuario-lista-crear') \ No newline at end of file