Add TemporaryUser model and related functionality for user registration and OTP verification
Some checks failed
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 1m19s

This commit is contained in:
i701 2025-04-16 11:01:43 +05:00
parent e0a80d4a00
commit dd21b848b9
7 changed files with 317 additions and 33 deletions

View File

@ -1,6 +1,6 @@
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin 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 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 # Re-register UserAdmin
admin.site.register(User, UserAdmin) admin.site.register(User, UserAdmin)
admin.site.register(Permission) admin.site.register(Permission)
admin.site.register(Atoll) admin.site.register(Atoll)
admin.site.register(Island) admin.site.register(Island)
admin.site.register(TemporaryUser, TemporaryUserAdmin)
# TokenAdmin.raw_id_fields = ["user"] # TokenAdmin.raw_id_fields = ["user"]

View File

@ -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",
},
),
]

View File

@ -29,6 +29,8 @@ class User(AbstractUser):
island = models.ForeignKey( island = models.ForeignKey(
"Island", on_delete=models.SET_NULL, null=True, blank=True, related_name="users" "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): def get_all_fields(self, instance):
return [field.name for field in instance.get_fields()] return [field.name for field in instance.get_fields()]
@ -36,7 +38,43 @@ class User(AbstractUser):
objects = CustomUserManager() 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_secret = models.CharField(max_length=50, default=pyotp.random_base32)
otp_verified = models.BooleanField(default=False) otp_verified = models.BooleanField(default=False)
@ -51,6 +89,13 @@ class TemporaryUser(User):
def is_expired(self): def is_expired(self):
return self.created_at < timezone.now() - timedelta(minutes=5) 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): class Atoll(models.Model):
name = models.CharField(max_length=255, unique=True) name = models.CharField(max_length=255, unique=True)

View File

@ -1,6 +1,6 @@
from knox.models import AuthToken from knox.models import AuthToken
from django.contrib.auth import authenticate 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 django.contrib.auth.models import Permission
from rest_framework import serializers from rest_framework import serializers
@ -82,6 +82,14 @@ class CustomUserByWalletBalanceSerializer(serializers.ModelSerializer):
fields = ("wallet_balance",) 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): class UserSerializer(serializers.ModelSerializer):
"""serializer for the user object""" """serializer for the user object"""
@ -142,5 +150,5 @@ class AtollSerializer(serializers.ModelSerializer):
class OTPVerificationSerializer(serializers.Serializer): class OTPVerificationSerializer(serializers.Serializer):
mobile = serializers.CharField() mobile = serializers.CharField(required=True, allow_blank=False)
otp = serializers.CharField() otp = serializers.CharField(required=True, allow_blank=False)

30
api/sms.py Normal file
View File

@ -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

View File

@ -4,7 +4,7 @@ from django.urls import path
from knox import views as knox_views from knox import views as knox_views
from .views import ( from .views import (
LoginView, LoginView,
CreateUserView, CreateTemporaryUserView,
ManageUserView, ManageUserView,
KnoxTokenListApiView, KnoxTokenListApiView,
ListUserView, ListUserView,
@ -18,11 +18,13 @@ from .views import (
RetrieveUpdateDestroyIslandView, RetrieveUpdateDestroyIslandView,
filter_user, filter_user,
UpdateUserWalletView, UpdateUserWalletView,
VerifyOTPView,
) )
urlpatterns = [ 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("profile/", ManageUserView.as_view(), name="profile"),
path("login/", LoginView.as_view(), name="knox_login"), path("login/", LoginView.as_view(), name="knox_login"),
path("logout/", knox_views.LogoutView.as_view(), name="knox_logout"), path("logout/", knox_views.LogoutView.as_view(), name="knox_logout"),

View File

@ -6,7 +6,7 @@ from rest_framework import generics, permissions
from rest_framework.authtoken.serializers import AuthTokenSerializer from rest_framework.authtoken.serializers import AuthTokenSerializer
from api.filters import UserFilter from api.filters import UserFilter
from api.mixins import StaffEditorPermissionMixin 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.response import Response
from rest_framework import status from rest_framework import status
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
@ -15,6 +15,8 @@ from api.serializers import (
AtollSerializer, AtollSerializer,
IslandSerializer, IslandSerializer,
CustomUserByWalletBalanceSerializer, CustomUserByWalletBalanceSerializer,
OTPVerificationSerializer,
TemporaryUserSerializer,
) )
# knox imports # knox imports
@ -25,11 +27,12 @@ import re
from typing import cast, Dict, Any from typing import cast, Dict, Any
from django.core.mail import send_mail from django.core.mail import send_mail
from django.db.models import Q from django.db.models import Q
from api.sms import send_otp
# local apps import # local apps import
from .serializers import ( from .serializers import (
KnoxTokenSerializer, KnoxTokenSerializer,
UserSerializer,
AuthSerializer, AuthSerializer,
CustomUserSerializer, CustomUserSerializer,
CustomReadOnlyUserSerializer, CustomReadOnlyUserSerializer,
@ -45,6 +48,7 @@ class ErrorMessages:
USERNAME_EXISTS = "Username already exists." USERNAME_EXISTS = "Username already exists."
MOBILE_EXISTS = "Mobile number already exists." MOBILE_EXISTS = "Mobile number already exists."
INVALID_ID_CARD = "Please enter a valid ID card number." 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_MOBILE = "Please enter a valid mobile number."
INVALID_ACCOUNT = "Please enter a valid account number." INVALID_ACCOUNT = "Please enter a valid account number."
@ -78,15 +82,14 @@ class UpdateUserWalletView(generics.UpdateAPIView):
return Response({"message": "Wallet balance updated successfully."}) return Response({"message": "Wallet balance updated successfully."})
class CreateUserView(generics.CreateAPIView): class CreateTemporaryUserView(generics.CreateAPIView):
# Create user API view # Create user API view
serializer_class = UserSerializer serializer_class = TemporaryUserSerializer
permission_classes = (permissions.AllowAny,) permission_classes = (permissions.AllowAny,)
queryset = User.objects.all() queryset = TemporaryUser.objects.all()
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
# Extract required fields from request data # Extract required fields from request data
password = request.data.get("password")
username = request.data.get("username") username = request.data.get("username")
address = request.data.get("address") address = request.data.get("address")
mobile = request.data.get("mobile") mobile = request.data.get("mobile")
@ -100,16 +103,19 @@ class CreateUserView(generics.CreateAPIView):
firstname = request.data.get("firstname") firstname = request.data.get("firstname")
lastname = request.data.get("lastname") 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) 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) 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): if id_card and not re.match(ID_CARD_PATTERN, id_card):
return Response({"message": ErrorMessages.INVALID_ID_CARD}, status=400) 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) return Response({"message": "ID card already exists."}, status=400)
if mobile is None or not re.match(MOBILE_PATTERN, mobile): 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) return Response({"message": "Island not found."}, status=404)
# Create user # Create user
user = User.objects.create_user( temp_user = TemporaryUser.objects.create(
first_name=firstname, t_first_name=firstname,
last_name=lastname, t_last_name=lastname,
username=str(username), t_username=str(username),
password=password, t_email=None,
email=None, t_address=address,
address=address, t_mobile=mobile,
mobile=mobile, t_acc_no=acc_no,
acc_no=acc_no, t_id_card=id_card,
id_card=id_card, t_dob=dob,
dob=dob, t_atoll=atoll,
atoll=atoll, t_island=island,
island=island, t_terms_accepted=terms_accepted,
terms_accepted=terms_accepted, t_policy_accepted=policy_accepted,
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) headers = self.get_success_headers(serializer.data)
return Response( return Response(
serializer.data, status=status.HTTP_201_CREATED, headers=headers serializer.data, status=status.HTTP_201_CREATED, headers=headers
@ -159,7 +170,6 @@ class CreateUserView(generics.CreateAPIView):
required_fields = { required_fields = {
"firstname": "First name", "firstname": "First name",
"lastname": "Last name", "lastname": "Last name",
"password": "Password",
"username": "Username", "username": "Username",
"address": "Address", "address": "Address",
"mobile": "Mobile number", "mobile": "Mobile number",
@ -182,6 +192,50 @@ class CreateUserView(generics.CreateAPIView):
return None 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): class LoginView(KnoxLoginView):
# login view extending KnoxLoginView # login view extending KnoxLoginView
serializer_class = AuthSerializer serializer_class = AuthSerializer