From dd21b848b90982e60ddc1dee6930433c447a6ab7 Mon Sep 17 00:00:00 2001 From: i701 Date: Wed, 16 Apr 2025 11:01:43 +0500 Subject: [PATCH] Add TemporaryUser model and related functionality for user registration and OTP verification --- api/admin.py | 50 ++++++++- ...reated_at_user_updated_at_temporaryuser.py | 97 ++++++++++++++++ api/models.py | 47 +++++++- api/serializers.py | 14 ++- api/sms.py | 30 +++++ api/urls.py | 6 +- api/views.py | 106 +++++++++++++----- 7 files changed, 317 insertions(+), 33 deletions(-) create mode 100644 api/migrations/0015_user_created_at_user_updated_at_temporaryuser.py create mode 100644 api/sms.py diff --git a/api/admin.py b/api/admin.py index 655efe0..7366a8d 100644 --- a/api/admin.py +++ b/api/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin -from api.models import User, Atoll, Island +from api.models import User, Atoll, Island, TemporaryUser from django.contrib.auth.models import Permission @@ -65,11 +65,59 @@ class UserAdmin(BaseUserAdmin): ) +class TemporaryUserAdmin(admin.ModelAdmin): + list_display = ( + "t_username", + "t_email", + "t_first_name", + "t_last_name", + "t_verified", + "t_mobile", + "t_address", + "t_wallet_balance", + "otp_verified", + "t_acc_no", + "t_id_card", + "t_dob", + "t_atoll", + "t_island", + "t_terms_accepted", + "t_policy_accepted", + ) + fieldsets = ( + (None, {"fields": ("t_username",)}), + ( + "Personal info", + { + "fields": ( + "t_first_name", + "t_last_name", + "t_email", + "t_mobile", + "t_address", + "t_verified", + "otp_verified", + "otp_expiry", + "t_wallet_balance", + "t_acc_no", + "t_id_card", + "t_dob", + "t_atoll", + "t_island", + "t_terms_accepted", + "t_policy_accepted", + ) + }, + ), + ) + + # Re-register UserAdmin admin.site.register(User, UserAdmin) admin.site.register(Permission) admin.site.register(Atoll) admin.site.register(Island) +admin.site.register(TemporaryUser, TemporaryUserAdmin) # TokenAdmin.raw_id_fields = ["user"] diff --git a/api/migrations/0015_user_created_at_user_updated_at_temporaryuser.py b/api/migrations/0015_user_created_at_user_updated_at_temporaryuser.py new file mode 100644 index 0000000..336d769 --- /dev/null +++ b/api/migrations/0015_user_created_at_user_updated_at_temporaryuser.py @@ -0,0 +1,97 @@ +# Generated by Django 5.2 on 2025-04-16 05:13 + +import django.db.models.deletion +import django.utils.timezone +import pyotp +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0014_alter_user_email"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="created_at", + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.AddField( + model_name="user", + name="updated_at", + field=models.DateTimeField(auto_now=True), + ), + migrations.CreateModel( + name="TemporaryUser", + fields=[ + ("t_id", models.AutoField(primary_key=True, serialize=False)), + ( + "t_username", + models.CharField( + blank=True, max_length=255, null=True, unique=True + ), + ), + ("t_first_name", models.CharField(blank=True, max_length=255)), + ("t_last_name", models.CharField(blank=True, max_length=255)), + ("t_address", models.CharField(blank=True, max_length=255)), + ( + "t_email", + models.EmailField( + blank=True, max_length=254, null=True, unique=True + ), + ), + ( + "t_mobile", + models.CharField( + blank=True, max_length=255, null=True, unique=True + ), + ), + ("t_designation", models.CharField(blank=True, max_length=255)), + ("t_acc_no", models.CharField(blank=True, max_length=255)), + ( + "t_id_card", + models.CharField( + blank=True, max_length=255, null=True, unique=True + ), + ), + ("t_verified", models.BooleanField(default=False)), + ("t_dob", models.DateField(blank=True, null=True)), + ("t_terms_accepted", models.BooleanField(default=False)), + ("t_policy_accepted", models.BooleanField(default=False)), + ("t_wallet_balance", models.FloatField(default=0.0)), + ("t_ninja_user_id", models.CharField(blank=True, max_length=255)), + ("created_at", models.DateTimeField(default=django.utils.timezone.now)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "otp_secret", + models.CharField(default=pyotp.random_base32, max_length=50), + ), + ("otp_verified", models.BooleanField(default=False)), + ( + "t_atoll", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="temp_users", + to="api.atoll", + ), + ), + ( + "t_island", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="temp_users", + to="api.island", + ), + ), + ], + options={ + "verbose_name": "Temporary User", + "verbose_name_plural": "Temporary Users", + }, + ), + ] diff --git a/api/models.py b/api/models.py index 8dbb93b..b1430c7 100644 --- a/api/models.py +++ b/api/models.py @@ -29,6 +29,8 @@ class User(AbstractUser): island = models.ForeignKey( "Island", on_delete=models.SET_NULL, null=True, blank=True, related_name="users" ) + created_at = models.DateTimeField(default=timezone.now) + updated_at = models.DateTimeField(auto_now=True) def get_all_fields(self, instance): return [field.name for field in instance.get_fields()] @@ -36,7 +38,43 @@ class User(AbstractUser): objects = CustomUserManager() -class TemporaryUser(User): +class TemporaryUser(models.Model): + t_id = models.AutoField(primary_key=True) + t_username = models.CharField(max_length=255, unique=True, blank=True, null=True) + t_first_name = models.CharField(max_length=255, blank=True) + t_last_name = models.CharField(max_length=255, blank=True) + t_address = models.CharField(max_length=255, blank=True) + t_email = models.EmailField(blank=True, null=True, unique=True) + t_mobile = models.CharField(max_length=255, blank=True, unique=True, null=True) + t_designation = models.CharField(max_length=255, blank=True) + t_acc_no = models.CharField(max_length=255, blank=True) + t_id_card = models.CharField(max_length=255, blank=True, unique=True, null=True) + t_verified = models.BooleanField(default=False) + t_dob = models.DateField(blank=True, null=True) + t_terms_accepted = models.BooleanField(default=False) + t_policy_accepted = models.BooleanField(default=False) + t_wallet_balance = models.FloatField(default=0.0) + t_ninja_user_id = models.CharField(max_length=255, blank=True) + t_atoll = models.ForeignKey( + "Atoll", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="temp_users", + ) + t_island = models.ForeignKey( + "Island", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="temp_users", + ) + created_at = models.DateTimeField(default=timezone.now) + updated_at = models.DateTimeField(auto_now=True) + + def get_all_fields(self, instance): + return [field.name for field in instance.get_fields()] + otp_secret = models.CharField(max_length=50, default=pyotp.random_base32) otp_verified = models.BooleanField(default=False) @@ -51,6 +89,13 @@ class TemporaryUser(User): def is_expired(self): return self.created_at < timezone.now() - timedelta(minutes=5) + class Meta: + verbose_name = "Temporary User" + verbose_name_plural = "Temporary Users" + + def __str__(self): + return self.t_username + class Atoll(models.Model): name = models.CharField(max_length=255, unique=True) diff --git a/api/serializers.py b/api/serializers.py index eb724e4..65bc3b7 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -1,6 +1,6 @@ from knox.models import AuthToken from django.contrib.auth import authenticate -from api.models import User, Atoll, Island +from api.models import User, Atoll, Island, TemporaryUser from django.contrib.auth.models import Permission from rest_framework import serializers @@ -82,6 +82,14 @@ class CustomUserByWalletBalanceSerializer(serializers.ModelSerializer): fields = ("wallet_balance",) +class TemporaryUserSerializer(serializers.ModelSerializer): + """serializer for the user object""" + + class Meta: # type: ignore + model = TemporaryUser + fields = ["t_username"] + + class UserSerializer(serializers.ModelSerializer): """serializer for the user object""" @@ -142,5 +150,5 @@ class AtollSerializer(serializers.ModelSerializer): class OTPVerificationSerializer(serializers.Serializer): - mobile = serializers.CharField() - otp = serializers.CharField() + mobile = serializers.CharField(required=True, allow_blank=False) + otp = serializers.CharField(required=True, allow_blank=False) diff --git a/api/sms.py b/api/sms.py new file mode 100644 index 0000000..bd486e0 --- /dev/null +++ b/api/sms.py @@ -0,0 +1,30 @@ +from decouple import config +import requests +import json +import logging + +logger = logging.getLogger(__name__) +api_url = config("SMS_API_URL") +api_key = config("SMS_API_KEY") + + +def send_otp(mobile: str, otp: int, message: str): + if not api_url or not api_key: + logger.debug("Failed to send SMS. Missing SMS_API_URL or SMS_API_KEY.") + return False + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + } + data = { + "number": mobile, + "message": message, + "check_delivery": False, + } + response = requests.post(api_url, headers=headers, data=json.dumps(data)) + if response.status_code == 200: + return True + else: + logger.debug(f"Failed to send SMS. Status code: {response.status_code}") + return False diff --git a/api/urls.py b/api/urls.py index 9e7f755..2e8c992 100644 --- a/api/urls.py +++ b/api/urls.py @@ -4,7 +4,7 @@ from django.urls import path from knox import views as knox_views from .views import ( LoginView, - CreateUserView, + CreateTemporaryUserView, ManageUserView, KnoxTokenListApiView, ListUserView, @@ -18,11 +18,13 @@ from .views import ( RetrieveUpdateDestroyIslandView, filter_user, UpdateUserWalletView, + VerifyOTPView, ) urlpatterns = [ - path("create/", CreateUserView.as_view(), name="create"), + path("register/", CreateTemporaryUserView.as_view(), name="register"), + path("register/verify/", VerifyOTPView.as_view(), name="verify-otp"), path("profile/", ManageUserView.as_view(), name="profile"), path("login/", LoginView.as_view(), name="knox_login"), path("logout/", knox_views.LogoutView.as_view(), name="knox_logout"), diff --git a/api/views.py b/api/views.py index e42bd25..012b360 100644 --- a/api/views.py +++ b/api/views.py @@ -6,7 +6,7 @@ from rest_framework import generics, permissions from rest_framework.authtoken.serializers import AuthTokenSerializer from api.filters import UserFilter from api.mixins import StaffEditorPermissionMixin -from api.models import User, Atoll, Island +from api.models import User, Atoll, Island, TemporaryUser from rest_framework.response import Response from rest_framework import status from rest_framework.exceptions import ValidationError @@ -15,6 +15,8 @@ from api.serializers import ( AtollSerializer, IslandSerializer, CustomUserByWalletBalanceSerializer, + OTPVerificationSerializer, + TemporaryUserSerializer, ) # knox imports @@ -25,11 +27,12 @@ import re from typing import cast, Dict, Any from django.core.mail import send_mail from django.db.models import Q +from api.sms import send_otp + # local apps import from .serializers import ( KnoxTokenSerializer, - UserSerializer, AuthSerializer, CustomUserSerializer, CustomReadOnlyUserSerializer, @@ -45,6 +48,7 @@ class ErrorMessages: USERNAME_EXISTS = "Username already exists." MOBILE_EXISTS = "Mobile number already exists." INVALID_ID_CARD = "Please enter a valid ID card number." + ID_CARD_EXISTS = "ID card already exists." INVALID_MOBILE = "Please enter a valid mobile number." INVALID_ACCOUNT = "Please enter a valid account number." @@ -78,15 +82,14 @@ class UpdateUserWalletView(generics.UpdateAPIView): return Response({"message": "Wallet balance updated successfully."}) -class CreateUserView(generics.CreateAPIView): +class CreateTemporaryUserView(generics.CreateAPIView): # Create user API view - serializer_class = UserSerializer + serializer_class = TemporaryUserSerializer permission_classes = (permissions.AllowAny,) - queryset = User.objects.all() + queryset = TemporaryUser.objects.all() def post(self, request, *args, **kwargs): # Extract required fields from request data - password = request.data.get("password") username = request.data.get("username") address = request.data.get("address") mobile = request.data.get("mobile") @@ -100,16 +103,19 @@ class CreateUserView(generics.CreateAPIView): firstname = request.data.get("firstname") lastname = request.data.get("lastname") - if User.objects.filter(mobile=mobile).exists(): + if TemporaryUser.objects.filter(t_mobile=mobile).exists(): return Response({"message": ErrorMessages.MOBILE_EXISTS}, status=400) - if User.objects.filter(username=username).exists(): + if TemporaryUser.objects.filter(t_username=username).exists(): return Response({"message": ErrorMessages.USERNAME_EXISTS}, status=400) + if TemporaryUser.objects.filter(t_id_card=id_card).exists(): + return Response({"message": ErrorMessages.ID_CARD_EXISTS}, status=400) + if id_card and not re.match(ID_CARD_PATTERN, id_card): return Response({"message": ErrorMessages.INVALID_ID_CARD}, status=400) - if User.objects.filter(id_card=id_card).exists(): + if TemporaryUser.objects.filter(t_id_card=id_card).exists(): return Response({"message": "ID card already exists."}, status=400) if mobile is None or not re.match(MOBILE_PATTERN, mobile): @@ -133,23 +139,28 @@ class CreateUserView(generics.CreateAPIView): return Response({"message": "Island not found."}, status=404) # Create user - user = User.objects.create_user( - first_name=firstname, - last_name=lastname, - username=str(username), - password=password, - email=None, - address=address, - mobile=mobile, - acc_no=acc_no, - id_card=id_card, - dob=dob, - atoll=atoll, - island=island, - terms_accepted=terms_accepted, - policy_accepted=policy_accepted, + temp_user = TemporaryUser.objects.create( + t_first_name=firstname, + t_last_name=lastname, + t_username=str(username), + t_email=None, + t_address=address, + t_mobile=mobile, + t_acc_no=acc_no, + t_id_card=id_card, + t_dob=dob, + t_atoll=atoll, + t_island=island, + t_terms_accepted=terms_accepted, + t_policy_accepted=policy_accepted, ) - serializer = self.get_serializer(user) + otp = temp_user.generate_otp() + send_otp( + temp_user.t_mobile, + otp, + f"Your Registration SARLink OTP is: {otp}, valid for 30 seconds. Please do not share it with anyone.", + ) + serializer = self.get_serializer(temp_user) headers = self.get_success_headers(serializer.data) return Response( serializer.data, status=status.HTTP_201_CREATED, headers=headers @@ -159,7 +170,6 @@ class CreateUserView(generics.CreateAPIView): required_fields = { "firstname": "First name", "lastname": "Last name", - "password": "Password", "username": "Username", "address": "Address", "mobile": "Mobile number", @@ -182,6 +192,50 @@ class CreateUserView(generics.CreateAPIView): return None +class VerifyOTPView(generics.GenericAPIView): + permission_classes = (permissions.AllowAny,) + serializer_class = OTPVerificationSerializer + + def post(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = request.data + print(data) + try: + temp_user = TemporaryUser.objects.get(t_mobile=data["mobile"]) + except TemporaryUser.DoesNotExist: + return Response({"message": "User not found."}, status=404) + + if temp_user.is_expired(): + return Response({"message": "OTP expired."}, status=400) + + if not temp_user.verify_otp(data["otp"]): + return Response({"message": "Invalid OTP."}, status=400) + + # Create real user + User.objects.create_user( + first_name=temp_user.t_first_name, + last_name=temp_user.t_last_name, + username=temp_user.t_username, + password="", + address=temp_user.t_address, + mobile=temp_user.t_mobile, + acc_no=temp_user.t_acc_no, + id_card=temp_user.t_id_card, + dob=temp_user.t_dob, + atoll=temp_user.t_atoll, + island=temp_user.t_island, + terms_accepted=temp_user.t_terms_accepted, + policy_accepted=temp_user.t_policy_accepted, + ) + + # You can now trigger registry verification as a signal or task + temp_user.otp_verified = True + temp_user.save() + + return Response({"message": "User created successfully."}) + + class LoginView(KnoxLoginView): # login view extending KnoxLoginView serializer_class = AuthSerializer