From ea248134661bde9b6de38a27184e454b3c8e4fed Mon Sep 17 00:00:00 2001 From: Sumit Singh Date: Sat, 8 Oct 2022 21:40:57 +0530 Subject: [PATCH] feat(deps): remove drfaddons dependency --- .pre-commit-config.yaml | 7 +- drf_user/__init__.py | 11 +- drf_user/constants.py | 16 +++ drf_user/models.py | 7 +- drf_user/serializers.py | 41 +++--- drf_user/signals/handlers.py | 15 +- drf_user/utils.py | 271 ++++++++++++++++++++++++----------- drf_user/variables.py | 10 -- drf_user/views.py | 222 ++++++++++++++-------------- example/demo_app/settings.py | 1 - requirements.txt | 2 +- tests/settings.py | 1 - tests/test_utils.py | 27 ++-- tests/test_views.py | 61 ++++---- 14 files changed, 400 insertions(+), 292 deletions(-) create mode 100644 drf_user/constants.py delete mode 100644 drf_user/variables.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1a4e80d..4f39c6e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,12 +9,7 @@ repos: rev: 5.0.4 hooks: - id: flake8 - args: [--max-line-length=88] - - - repo: https://github.com/asottile/reorder_python_imports - rev: v3.8.3 - hooks: - - id: reorder-python-imports + args: [--max-line-length=100] - repo: https://github.com/econchick/interrogate rev: 1.5.0 diff --git a/drf_user/__init__.py b/drf_user/__init__.py index a433ee6..0e8700f 100644 --- a/drf_user/__init__.py +++ b/drf_user/__init__.py @@ -55,17 +55,16 @@ def update_user_settings() -> dict: user_settings[key] = value elif key == "OTP": if not isinstance(value, dict): - raise TypeError("USER_SETTING attribute OTP must be a" " dict.") + raise TypeError("USER_SETTING attribute OTP must be a dict.") for otp_key, otp_value in value.items(): user_settings["OTP"][otp_key] = otp_value elif key == "REGISTRATION": - if isinstance(value, dict): - for reg_key, reg_value in value.items(): - user_settings["REGISTRATION"][reg_key] = reg_value - else: + if not isinstance(value, dict): raise TypeError( - "USER_SETTING attribute REGISTRATION" " must be a dict." + "USER_SETTING attribute REGISTRATION must be a dict." ) + for reg_key, reg_value in value.items(): + user_settings["REGISTRATION"][reg_key] = reg_value if user_settings["REGISTRATION"]["SEND_MAIL"]: if not getattr(settings, "EMAIL_HOST", None): raise ValueError( diff --git a/drf_user/constants.py b/drf_user/constants.py new file mode 100644 index 0000000..6bae8d2 --- /dev/null +++ b/drf_user/constants.py @@ -0,0 +1,16 @@ +""" +All constants used in the system. +""" + +EMAIL: str = "E" +MOBILE: str = "M" +DESTINATION_CHOICES: list = [(EMAIL, "EMail Address"), (MOBILE, "Mobile Number")] + + +class CoreConstants: + """Core Constants""" + + EMAIL_PROP: str = "E" + MOBILE_PROP: str = "M" + EMAIL_STR: str = "email" + MOBILE_STR: str = "mobile" diff --git a/drf_user/models.py b/drf_user/models.py index ff9bfe7..726a5cf 100644 --- a/drf_user/models.py +++ b/drf_user/models.py @@ -6,8 +6,7 @@ from django.utils.text import gettext_lazy as _ from drf_user.managers import UserManager -from drf_user.variables import DESTINATION_CHOICES -from drf_user.variables import EMAIL +from drf_user.constants import DESTINATION_CHOICES, EMAIL class Role(Group): @@ -86,7 +85,7 @@ def get_full_name(self) -> str: def __str__(self): """String representation of model""" - return str(self.name) + " | " + str(self.username) + return f"{str(self.name)} | {str(self.username)}" class AuthTransaction(models.Model): @@ -118,7 +117,7 @@ class AuthTransaction(models.Model): def __str__(self): """String representation of model""" - return str(self.created_by.name) + " | " + str(self.created_by.username) + return f"{str(self.created_by.name)} | {str(self.created_by.username)}" class Meta: """Passing model metadata""" diff --git a/drf_user/serializers.py b/drf_user/serializers.py index 0103159..e8258d1 100644 --- a/drf_user/serializers.py +++ b/drf_user/serializers.py @@ -10,8 +10,7 @@ from drf_user import user_settings from drf_user.models import User from drf_user.utils import check_validation -from drf_user.variables import EMAIL -from drf_user.variables import MOBILE +from drf_user.constants import EMAIL, MOBILE class UserSerializer(serializers.ModelSerializer): @@ -203,8 +202,7 @@ def validate(self, attrs: dict) -> dict: if "email" not in attrs.keys() and "verify_otp" not in attrs.keys(): raise serializers.ValidationError( _( - "email field is compulsory while verifying a" - " non-existing user's OTP." + "Email field is compulsory while verifying a non-existing user's OTP." ) ) else: @@ -250,26 +248,27 @@ def get_user(email: str, mobile: str): except User.DoesNotExist: try: user = User.objects.get(mobile=mobile) - except User.DoesNotExist: - user = None + except User.DoesNotExist as e: + raise NotFound( + _(f"No user exists either for email={email} or mobile={mobile}") + ) from e - if user: - if user.email != email: - raise serializers.ValidationError( - _( - "Your account is registered with {mobile} does not has " - "{email} as registered email. Please login directly via " - "OTP with your mobile.".format(mobile=mobile, email=email) - ) + if user.email != email: + raise serializers.ValidationError( + _( + "Your account is registered with {mobile} does not has " + "{email} as registered email. Please login directly via " + "OTP with your mobile.".format(mobile=mobile, email=email) ) - if user.mobile != mobile: - raise serializers.ValidationError( - _( - "Your account is registered with {email} does not has " - "{mobile} as registered mobile. Please login directly via " - "OTP with your email.".format(mobile=mobile, email=email) - ) + ) + if user.mobile != mobile: + raise serializers.ValidationError( + _( + "Your account is registered with {email} does not has " + "{mobile} as registered mobile. Please login directly via " + "OTP with your email.".format(mobile=mobile, email=email) ) + ) return user def validate(self, attrs: dict) -> dict: diff --git a/drf_user/signals/handlers.py b/drf_user/signals/handlers.py index dccf32c..f4ce5f5 100644 --- a/drf_user/signals/handlers.py +++ b/drf_user/signals/handlers.py @@ -3,9 +3,11 @@ from django.db.models.signals import post_save from django.dispatch import receiver +User = get_user_model() -@receiver(post_save, sender=get_user_model()) -def post_register(sender, instance: get_user_model(), created, **kwargs): + +@receiver(post_save, sender=User) +def post_register(sender, instance: User, created: bool, **kwargs): """Sends mail/message to users after registeration Parameters @@ -19,21 +21,20 @@ def post_register(sender, instance: get_user_model(), created, **kwargs): from drf_user import user_settings - from drfaddons.utils import send_message + from drf_user.utils import send_message if created: if user_settings["REGISTRATION"]["SEND_MAIL"]: send_message( message=user_settings["REGISTRATION"]["TEXT_MAIL_BODY"], subject=user_settings["REGISTRATION"]["MAIL_SUBJECT"], - recip=[instance.email], - recip_email=[instance.email], + recip_email=instance.email, html_message=user_settings["REGISTRATION"]["HTML_MAIL_BODY"], ) if user_settings["REGISTRATION"]["SEND_MESSAGE"]: send_message( message=user_settings["REGISTRATION"]["SMS_BODY"], subject=user_settings["REGISTRATION"]["MAIL_SUBJECT"], - recip=[instance.mobile], - recip_email=[instance.mobile], + recip_email=instance.email, + recip_mobile=instance.mobile, ) diff --git a/drf_user/utils.py b/drf_user/utils.py index 8070c8c..6dea184 100644 --- a/drf_user/utils.py +++ b/drf_user/utils.py @@ -1,30 +1,29 @@ """Collection of general helper functions.""" import datetime -from typing import Dict -from typing import Optional -from typing import Union - -import pytz +import logging +import re +from typing import Dict, Optional + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.core.mail import send_mail +from django.core.validators import validate_email from django.http import HttpRequest from django.utils import timezone from django.utils.text import gettext_lazy as _ -from drfaddons.utils import send_message -from rest_framework.exceptions import APIException -from rest_framework.exceptions import AuthenticationFailed -from rest_framework.exceptions import NotFound -from rest_framework.exceptions import PermissionDenied +from rest_framework import serializers +from rest_framework.exceptions import AuthenticationFailed, NotFound, PermissionDenied from rest_framework_simplejwt.tokens import RefreshToken from rest_framework_simplejwt.utils import datetime_from_epoch +from sendsms import api from drf_user import update_user_settings -from drf_user.models import AuthTransaction -from drf_user.models import OTPValidation -from drf_user.models import User +from drf_user.models import AuthTransaction, OTPValidation, User + +user_settings: dict = update_user_settings() +otp_settings: dict = user_settings["OTP"] -user_settings: Dict[ - str, Union[bool, Dict[str, Union[int, str, bool]]] -] = update_user_settings() -otp_settings: Dict[str, Union[str, int]] = user_settings["OTP"] +logger = logging.getLogger(__name__) def get_client_ip(request: HttpRequest) -> Optional[str]: @@ -51,7 +50,7 @@ def get_client_ip(request: HttpRequest) -> Optional[str]: def datetime_passed_now(source: datetime.datetime) -> bool: """ Compares provided datetime with current time on the basis of Django - settings. Checks source is in future or in past. False if it's in future. + settings. Checks source is in future or in the past. False if it's in future. Parameters ---------- source: datetime object than may or may not be naive @@ -63,9 +62,9 @@ def datetime_passed_now(source: datetime.datetime) -> bool: Author: Himanshu Shankar (https://himanshus.com) """ if source.tzinfo is not None and source.tzinfo.utcoffset(source) is not None: - return source <= datetime.datetime.utcnow().replace(tzinfo=pytz.utc) - else: - return source <= datetime.datetime.now() + return source <= datetime.datetime.now(datetime.timezone.utc) + + return source <= datetime.datetime.now() def check_unique(prop: str, value: str) -> bool: @@ -97,18 +96,18 @@ def check_unique(prop: str, value: str) -> bool: return user.count() == 0 -def generate_otp(prop: str, value: str) -> OTPValidation: +def generate_otp(*, destination_property: str, destination: str) -> OTPValidation: """ This function generates an OTP and saves it into Model. It also sets various counters, such as send_counter, is_validated, validate_attempt. Parameters ---------- - prop: str + destination_property: str This specifies the type for which OTP is being created. Can be:: - email - mobile - value: str + E + M + destination: str This specifies the value for which OTP is being created. Returns @@ -118,10 +117,10 @@ def generate_otp(prop: str, value: str) -> OTPValidation: Examples -------- To create an OTP for an Email test@testing.com - >>> print(generate_otp('email', 'test@testing.com')) + >>> print(generate_otp('E', 'test@testing.com')) OTPValidation object - >>> print(generate_otp('email', 'test@testing.com').otp) + >>> print(generate_otp('E', 'test@testing.com').otp) 5039164 """ # Create a random number @@ -141,16 +140,16 @@ def generate_otp(prop: str, value: str) -> OTPValidation: # Get or Create new instance of Model with value of provided value # and set proper counter. try: - otp_object: OTPValidation = OTPValidation.objects.get(destination=value) + otp_object: OTPValidation = OTPValidation.objects.get(destination=destination) except OTPValidation.DoesNotExist: otp_object: OTPValidation = OTPValidation() - otp_object.destination = value + otp_object.destination = destination else: if not datetime_passed_now(otp_object.reactive_at): return otp_object otp_object.otp = random_number - otp_object.prop = prop + otp_object.prop = destination_property # Set is_validated to False otp_object.is_validated = False @@ -164,48 +163,6 @@ def generate_otp(prop: str, value: str) -> OTPValidation: return otp_object -def send_otp(value: str, otpobj: OTPValidation, recip: str) -> Dict: - """ - This function sends OTP to specified value. - Parameters - ---------- - value: str - This is the value at which and for which OTP is to be sent. - otpobj: OTPValidation - This is the OTP or One Time Passcode that is to be sent to user. - recip: str - This is the recipient to whom EMail is being sent. This will be - deprecated once SMS feature is brought in. - - Returns - ------- - - """ - otp: str = otpobj.otp - - if not datetime_passed_now(otpobj.reactive_at): - raise PermissionDenied( - detail=_(f"OTP sending not allowed until: {otpobj.reactive_at}") - ) - - message = ( - f"OTP for verifying {otpobj.get_prop_display()}: {value} is {otp}." - f" Don't share this with anyone!" - ) - - try: - rdata: dict = send_message(message, otp_settings["SUBJECT"], [value], [recip]) - except ValueError as err: - raise APIException(_(f"Server configuration error occurred: {err}")) - - otpobj.reactive_at = timezone.now() + datetime.timedelta( - minutes=otp_settings["COOLING_PERIOD"] - ) - otpobj.save() - - return rdata - - def login_user(user: User, request: HttpRequest) -> Dict[str, str]: """ This function is used to login a user. It saves the authentication in @@ -278,54 +235,198 @@ def check_validation(value: str) -> bool: return False -def validate_otp(value: str, otp: int) -> bool: +def validate_mobile(mobile: str) -> bool: + """ + This function checks if the mobile number is valid or not. + Parameters + ---------- + mobile: str + This is the mobile number to be checked. + + Returns + ------- + bool + True if mobile number is valid, False otherwise. + Examples + -------- + To check if '9999999999' is a valid mobile number + >>> print(validate_mobile('9999999999')) + True + """ + if is_valid := re.match(r"^[6-9]\d{9}$", mobile) is not None: + return is_valid + raise ValidationError("Invalid Mobile Number") + + +def validate_otp(*, destination: str, otp_val: int) -> bool: """ This function is used to validate the OTP for a particular value. It also reduces the attempt count by 1 and resets OTP. Parameters ---------- - value: str + destination: str This is the unique entry for which OTP has to be validated. - otp: int + otp_val: int This is the OTP that will be validated against one in Database. Returns ------- bool: True, if OTP is validated """ + try: # Try to get OTP Object from Model and initialize data dictionary otp_object: OTPValidation = OTPValidation.objects.get( - destination=value, is_validated=False + destination=destination, is_validated=False ) - except OTPValidation.DoesNotExist: + except OTPValidation.DoesNotExist as e: raise NotFound( detail=_( - "No pending OTP validation request found for provided " - "destination. Kindly send an OTP first" + "No pending OTP validation request found for provided destination." + " Kindly send an OTP first" ) - ) + ) from e + # Decrement validate_attempt otp_object.validate_attempt -= 1 - if str(otp_object.otp) == str(otp): + if str(otp_object.otp) == str(otp_val): # match otp otp_object.is_validated = True - otp_object.save() + otp_object.save(update_fields=["is_validated", "validate_attempt"]) return True elif otp_object.validate_attempt <= 0: # check if attempts exceeded and regenerate otp and raise error - generate_otp(otp_object.prop, value) + generate_otp(destination_property=otp_object.prop, destination=destination) raise AuthenticationFailed( detail=_("Incorrect OTP. Attempt exceeded! OTP has been reset.") ) else: # update attempts and raise error - otp_object.save() + otp_object.save(update_fields=["validate_attempt"]) raise AuthenticationFailed( detail=_( f"OTP Validation failed! {otp_object.validate_attempt} attempts left!" ) ) + + +def send_message( + message: str, + subject: str, + recip_email: str, + recip_mobile: Optional[str] = None, + html_message: Optional[str] = None, +) -> Dict: + """ + Sends message to specified value. + + Parameters + ---------- + message: str + Message that is to be sent to user. + subject: str + Subject that is to be sent to user, in case prop is an email. + recip_mobile: str + Recipient Mobile Number to whom message is being sent. + recip_email: str + Recipient to whom EMail is being sent. + html_message: str + HTML variant of message, if any. + + Returns + ------- + sent: dict + """ + sent = {"success": False, "message": None, "mobile_message": None} + + if not getattr(settings, "EMAIL_HOST", None): + raise ValueError( + "EMAIL_HOST must be defined in django setting for sending mail." + ) + if not getattr(settings, "EMAIL_FROM", None): + raise ValueError( + "EMAIL_FROM must be defined in django setting " + "for sending mail. Who is sending email?" + ) + + # check if email is valid + validate_email(recip_email) + + if recip_mobile: + # check for valid mobile numbers + validate_mobile(recip_mobile) + + try: + send_mail( + subject=subject, + message=message, + html_message=html_message, + from_email=settings.EMAIL_FROM, + recipient_list=[recip_email], + ) + except Exception as e: # noqa + sent["message"] = f"Email Message sending failed! {str(e.args)}" + sent["success"] = False + else: + sent["message"] = "Email Message sent successfully!" + sent["success"] = True + + if recip_mobile: + try: + api.send_sms(body=message, to=recip_mobile, from_phone=None) + except Exception as e: # noqa + logger.debug("Message sending failed", exc_info=e) + sent["mobile_message"] = f"Mobile Message sending failed! {str(e.args)}" + else: + sent["mobile_message"] = "Mobile Message sent successfully!" + + return sent + + +def send_otp( + *, otp_obj: OTPValidation, recip_email: str, recip_mobile: Optional[str] = None +) -> Dict: + """ + This function sends OTP to specified value. + Parameters + ---------- + otp_obj: OTPValidation + OTPValidation object that contains the OTP and other details. + recip_email: str + Recipient to whom EMail is being sent. + recip_mobile: Optional[str] + Recipient Mobile Number to whom message is being sent. + + Returns + ------- + data: dict + Dictionary containing the status of the OTP sent. + """ + otp_val: str = otp_obj.otp + + if not datetime_passed_now(otp_obj.reactive_at): + raise PermissionDenied(f"OTP sending not allowed until: {otp_obj.reactive_at}") + + message: str = ( + f"OTP for verifying {otp_obj.get_prop_display()}: {otp_obj.destination} is {otp_val}." + f" Don't share this with anyone!" + ) + + try: + data: dict = send_message( + message, otp_settings["SUBJECT"], recip_email, recip_mobile + ) + except (ValueError, ValidationError) as e: + raise serializers.ValidationError( + {"detail": f"OTP sending failed! because {e}"} + ) from e + + otp_obj.reactive_at = timezone.now() + datetime.timedelta( + minutes=otp_settings["COOLING_PERIOD"] + ) + otp_obj.save(update_fields=["reactive_at"]) + + return data diff --git a/drf_user/variables.py b/drf_user/variables.py deleted file mode 100644 index baa231a..0000000 --- a/drf_user/variables.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -All static variables used in the system. - -Author: Himanshu Shankar (https://himanshus.com) -Author: Aditya Gupta (https://github.com/ag93999) -""" - -EMAIL = "E" -MOBILE = "M" -DESTINATION_CHOICES = [(EMAIL, "EMail Address"), (MOBILE, "Mobile Number")] diff --git a/drf_user/views.py b/drf_user/views.py index 82433c9..a9ed099 100644 --- a/drf_user/views.py +++ b/drf_user/views.py @@ -1,40 +1,46 @@ """Views for drf-user""" +from typing import Optional + from django.conf import settings +from django.contrib.auth import get_user_model +from django.db.models import F from django.utils import timezone from django.utils.text import gettext_lazy as _ -from drfaddons.utils import JsonResponse -from rest_framework import status -from rest_framework.exceptions import APIException +from rest_framework import status, serializers from rest_framework.exceptions import ValidationError -from rest_framework.generics import CreateAPIView -from rest_framework.generics import RetrieveUpdateAPIView +from rest_framework.generics import CreateAPIView, RetrieveUpdateAPIView from rest_framework.parsers import JSONParser +from rest_framework.parsers import MultiPartParser from rest_framework.permissions import AllowAny from rest_framework.permissions import IsAuthenticated from rest_framework.renderers import JSONRenderer from rest_framework.response import Response from rest_framework.views import APIView -from rest_framework_simplejwt.exceptions import InvalidToken -from rest_framework_simplejwt.exceptions import TokenError +from rest_framework_simplejwt.exceptions import InvalidToken, TokenError from rest_framework_simplejwt.settings import api_settings from rest_framework_simplejwt.views import TokenRefreshView -from drf_user.models import AuthTransaction -from drf_user.models import User -from drf_user.serializers import CheckUniqueSerializer -from drf_user.serializers import CustomTokenObtainPairSerializer -from drf_user.serializers import OTPLoginRegisterSerializer -from drf_user.serializers import OTPSerializer -from drf_user.serializers import PasswordResetSerializer -from drf_user.serializers import UserSerializer -from drf_user.utils import check_unique -from drf_user.utils import generate_otp -from drf_user.utils import get_client_ip -from drf_user.utils import login_user -from drf_user.utils import send_otp -from drf_user.utils import validate_otp -from drf_user.variables import EMAIL -from drf_user.variables import MOBILE +from drf_user.models import AuthTransaction, OTPValidation +from drf_user.serializers import ( + CheckUniqueSerializer, + CustomTokenObtainPairSerializer, + OTPLoginRegisterSerializer, + OTPSerializer, + PasswordResetSerializer, + UserSerializer, + ImageSerializer, +) +from drf_user.utils import ( + check_unique, + generate_otp, + get_client_ip, + login_user, + validate_otp, + send_otp, +) +from drf_user.constants import EMAIL, CoreConstants + +User = get_user_model() class RegisterView(CreateAPIView): @@ -59,9 +65,9 @@ def perform_create(self, serializer): } try: data["mobile"] = serializer.validated_data["mobile"] - except KeyError: + except KeyError as e: if not settings.USER_SETTINGS["MOBILE_OPTIONAL"]: - raise ValidationError({"error": "Mobile is required."}) + raise ValidationError({"error": "Mobile is required."}) from e return User.objects.create_user(**data) @@ -69,7 +75,7 @@ class LoginView(APIView): """ Login View - This is used to Login into system. + This is used to Log in into system. The data required are 'username' and 'password'. username -- Either username or mobile or email address. @@ -126,26 +132,22 @@ class CheckUniqueView(APIView): permission_classes = (AllowAny,) serializer_class = CheckUniqueSerializer - def validated(self, serialized_data, *args, **kwargs): - """Validates the response""" - return ( - { - "unique": check_unique( - serialized_data.validated_data["prop"], - serialized_data.validated_data["value"], - ) - }, - status.HTTP_200_OK, - ) - def post(self, request): """Overrides post method to validate serialized data""" serialized_data = self.serializer_class(data=request.data) if serialized_data.is_valid(): - return JsonResponse(self.validated(serialized_data=serialized_data)) + return Response( + data={ + "unique": check_unique( + serialized_data.validated_data["prop"], + serialized_data.validated_data["value"], + ) + }, + status=status.HTTP_200_OK, + ) else: - return JsonResponse( - serialized_data.errors, status=status.HTTP_422_UNPROCESSABLE_ENTITY + return Response( + data=serialized_data.errors, status=status.HTTP_422_UNPROCESSABLE_ENTITY ) @@ -185,43 +187,52 @@ class OTPView(APIView): def post(self, request, *args, **kwargs): """Overrides post method to validate serialized data""" - serializer = self.serializer_class(data=request.data) + serializer: OTPSerializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) - destination = serializer.validated_data.get("destination") - prop = serializer.validated_data.get("prop") - user = serializer.validated_data.get("user") - email = serializer.validated_data.get("email") - is_login = serializer.validated_data.get("is_login") + destination: str = serializer.validated_data[ + "destination" + ] # destination is a required field + destination_property: str = serializer.validated_data.get( + "prop" + ) # can be email or mobile + user: User = serializer.validated_data.get("user") + email: Optional[str] = serializer.validated_data.get("email") + is_login: bool = serializer.validated_data.get("is_login") if "verify_otp" in request.data.keys(): - if validate_otp(destination, request.data.get("verify_otp")): + if validate_otp( + destination=destination, otp_val=request.data["verify_otp"] + ): if is_login: return Response( login_user(user, self.request), status=status.HTTP_202_ACCEPTED ) else: return Response( - data={ - "OTP": [ - _("OTP Validated successfully!"), - ] - }, + data={"OTP": _("OTP Validated successfully!")}, status=status.HTTP_202_ACCEPTED, ) else: - otp_obj = generate_otp(prop, destination) - sentotp = send_otp(destination, otp_obj, email) + otp_obj: OTPValidation = generate_otp( + destination_property=destination_property, destination=destination + ) + recip_mobile: Optional[str] = None + if destination_property == CoreConstants.MOBILE_PROP: + recip_mobile = destination - if sentotp["success"]: - otp_obj.send_counter += 1 - otp_obj.save() + sent_otp_resp: dict = send_otp( + otp_obj=otp_obj, recip_email=email, recip_mobile=recip_mobile + ) - return Response(sentotp, status=status.HTTP_201_CREATED) - else: - raise APIException( - detail=_("A Server Error occurred: " + sentotp["message"]) - ) + if sent_otp_resp["success"]: + otp_obj.send_counter = F("send_counter") + 1 + otp_obj.save(update_fields=["send_counter"]) + return Response(sent_otp_resp, status=status.HTTP_201_CREATED) + + raise serializers.ValidationError( + detail=_(f"OTP could not be sent! {sent_otp_resp['message']}") + ) class RetrieveUpdateUserAccountView(RetrieveUpdateAPIView): @@ -283,14 +294,14 @@ def post(self, request, *args, **kwargs): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) - verify_otp = serializer.validated_data.get("verify_otp", None) - name = serializer.validated_data.get("name") - mobile = serializer.validated_data.get("mobile") - email = serializer.validated_data.get("email") - user = serializer.validated_data.get("user", None) + verify_otp = serializer.validated_data.get("verify_otp") + name = serializer.validated_data["name"] + mobile = serializer.validated_data["mobile"] + email = serializer.validated_data["email"] + user: User = serializer.validated_data.get("user") if verify_otp: - if validate_otp(email, verify_otp) and not user: + if validate_otp(destination=email, otp_val=verify_otp) and not user: user = User.objects.create_user( name=name, mobile=mobile, @@ -299,51 +310,32 @@ def post(self, request, *args, **kwargs): password=User.objects.make_random_password(), ) user.is_active = True - user.save() + user.save(update_fields=["is_active"]) return Response( login_user(user, self.request), status=status.HTTP_202_ACCEPTED ) - else: - otp_obj_email = generate_otp(EMAIL, email) - otp_obj_mobile = generate_otp(MOBILE, mobile) - - # Set same OTP for both Email & Mobile - otp_obj_mobile.otp = otp_obj_email.otp - otp_obj_mobile.save() - - # Send OTP to Email & Mobile - sentotp_email = send_otp(email, otp_obj_email, email) - sentotp_mobile = send_otp(mobile, otp_obj_mobile, email) - - message = {} - - if sentotp_email["success"]: - otp_obj_email.send_counter += 1 - otp_obj_email.save() - message["email"] = {"otp": _("OTP has been sent successfully.")} - else: - message["email"] = { - "otp": _(f'OTP sending failed {sentotp_email["message"]}') - } - - if sentotp_mobile["success"]: - otp_obj_mobile.send_counter += 1 - otp_obj_mobile.save() - message["mobile"] = {"otp": _("OTP has been sent successfully.")} - else: - message["mobile"] = { - "otp": _(f'OTP sending failed {sentotp_mobile["message"]}') - } - - if sentotp_email["success"] or sentotp_mobile["success"]: - curr_status = status.HTTP_201_CREATED - else: - raise APIException( - detail=_("A Server Error occurred: " + sentotp_mobile["message"]) - ) + otp_obj: OTPValidation = generate_otp( + destination_property=EMAIL, destination=email + ) + # Send OTP to Email & Mobile + sent_otp_resp: dict = send_otp( + otp_obj=otp_obj, recip_email=email, recip_mobile=mobile + ) - return Response(data=message, status=curr_status) + if not sent_otp_resp["success"]: + raise serializers.ValidationError( + detail=_(f"OTP could not be sent! {sent_otp_resp['message']}") + ) + + otp_obj.send_counter = F("send_counter") + 1 + otp_obj.save(update_fields=["send_counter"]) + message = { + "email": {"otp": sent_otp_resp["message"]}, + "mobile_message": {"otp": sent_otp_resp["mobile_message"]}, + } + + return Response(data=message, status=status.HTTP_201_CREATED) class PasswordResetView(APIView): @@ -363,13 +355,14 @@ def post(self, request, *args, **kwargs): user = User.objects.get(email=serializer.validated_data["email"]) if validate_otp( - serializer.validated_data["email"], serializer.validated_data["otp"] + destination=serializer.validated_data["email"], + otp_val=serializer.validated_data["otp"], ): # OTP Validated, Change Password user.set_password(serializer.validated_data["password"]) user.save() - return JsonResponse( - content="Password Updated Successfully.", + return Response( + data="Password Updated Successfully.", status=status.HTTP_202_ACCEPTED, ) @@ -381,11 +374,6 @@ class UploadImageView(APIView): attached to `profile_image` parameter. """ - from .models import User - from .serializers import ImageSerializer - from rest_framework.permissions import IsAuthenticated - from rest_framework.parsers import MultiPartParser - queryset = User.objects.all() serializer_class = ImageSerializer permission_classes = (IsAuthenticated,) diff --git a/example/demo_app/settings.py b/example/demo_app/settings.py index 2ceee6b..4b1569b 100644 --- a/example/demo_app/settings.py +++ b/example/demo_app/settings.py @@ -38,7 +38,6 @@ "django.contrib.messages", "django.contrib.staticfiles", "drf_user", - "drfaddons", "rest_framework", "django_filters", "drf_yasg", diff --git a/requirements.txt b/requirements.txt index 8e7c1d2..b25370a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ Django>=3.2 django-filter==22.1 +django-sendsms>=0.3.1 djangorestframework>=3.12 djangorestframework-simplejwt>=5.0.0 -drfaddons>=0.1.0 Pillow>=8.0.0 diff --git a/tests/settings.py b/tests/settings.py index e7bdfec..68f2743 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -13,7 +13,6 @@ "django.contrib.messages", "django.contrib.staticfiles", "drf_user", - "drfaddons", "rest_framework", "django_filters", ) diff --git a/tests/test_utils.py b/tests/test_utils.py index fe18c47..a14afe4 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -9,6 +9,7 @@ from rest_framework.exceptions import AuthenticationFailed from drf_user import utils as utils +from drf_user.constants import CoreConstants from drf_user.models import OTPValidation from drf_user.models import User from drf_user.utils import get_client_ip @@ -71,7 +72,9 @@ class TestGenerateOTP(TestCase): @pytest.mark.django_db def test_generate_otp(self): """Check generate_otp successfully generates OTPValidation object or not""" - utils.generate_otp("email", "user1@email.com") + utils.generate_otp( + destination_property=CoreConstants.EMAIL_PROP, destination="user1@email.com" + ) self.assertEqual(1, OTPValidation.objects.count()) @pytest.mark.django_db @@ -79,8 +82,12 @@ def test_generate_otp_reactive_past(self): """ Check generate_otp generates a new otp if the reactive time is yet to be over """ - otp_validation1 = utils.generate_otp("email", "user1@email.com") - otp_validation2 = utils.generate_otp("email", "user1@email.com") + otp_validation1 = utils.generate_otp( + destination_property=CoreConstants.EMAIL_PROP, destination="user1@email.com" + ) + otp_validation2 = utils.generate_otp( + destination_property=CoreConstants.EMAIL_PROP, destination="user1@email.com" + ) self.assertNotEqual(otp_validation1.otp, otp_validation2.otp) @pytest.mark.django_db @@ -88,7 +95,9 @@ def test_generate_otp_reactive_future(self): """ Check generate_otp returns the same otp if the reactive time is already over """ - otp_validation1 = utils.generate_otp("email", "user1@email.com") + otp_validation1 = utils.generate_otp( + destination_property=CoreConstants.EMAIL_PROP, destination="user1@email.com" + ) """ Simulating that the reactive time is already been over 5 minutes ago @@ -96,7 +105,9 @@ def test_generate_otp_reactive_future(self): otp_validation1.reactive_at = timezone.now() + datetime.timedelta(minutes=5) otp_validation1.save() - otp_validation2 = utils.generate_otp("email", "user1@email.com") + otp_validation2 = utils.generate_otp( + destination_property=CoreConstants.EMAIL_PROP, destination="user1@email.com" + ) self.assertEqual(otp_validation2.otp, otp_validation1.otp) @@ -117,7 +128,7 @@ def test_object_created(self): @pytest.mark.django_db def test_validate_otp(self): """Check if OTPValidation object is created or not""" - self.assertTrue(utils.validate_otp("user@email.com", 12345)) + self.assertTrue(utils.validate_otp(destination="user@email.com", otp_val=12345)) @pytest.mark.django_db def test_validate_otp_raises_attempt_exceeded_exception(self): @@ -130,7 +141,7 @@ def test_validate_otp_raises_attempt_exceeded_exception(self): self.otp_validation.save() with self.assertRaises(AuthenticationFailed) as context_manager: - utils.validate_otp("user@email.com", 56123) + utils.validate_otp(destination="user@email.com", otp_val=56123) self.assertEqual( "Incorrect OTP. Attempt exceeded! OTP has been reset.", @@ -141,7 +152,7 @@ def test_validate_otp_raises_attempt_exceeded_exception(self): def test_validate_otp_raises_invalid_otp_exception(self): """Check function raises attempt exceeded exception""" with self.assertRaises(AuthenticationFailed) as context_manager: - utils.validate_otp("user@email.com", 5623) + utils.validate_otp(destination="user@email.com", otp_val=5623) self.assertEqual( "OTP Validation failed! 2 attempts left!", diff --git a/tests/test_views.py b/tests/test_views.py index 4722164..aaf5a5c 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -5,6 +5,7 @@ from django.test import override_settings from django.urls import reverse from model_bakery import baker +from rest_framework import status from rest_framework.test import APITestCase from rest_framework_simplejwt.tokens import RefreshToken @@ -180,21 +181,21 @@ def test_is_unique(self): response = self.client.post(self.url, {"prop": "username", "value": "user7"}) self.assertEqual(200, response.status_code) - self.assertTrue(response.json()["data"][0]["unique"]) + self.assertTrue(response.json()["unique"]) @pytest.mark.django_db def test_is_not_unique(self): """Check if the user is not unique""" response = self.client.post(self.url, {"prop": "username", "value": "user"}) - self.assertEqual(200, response.status_code) - self.assertFalse(response.json()["data"][0]["unique"]) + self.assertEqual(status.HTTP_200_OK, response.status_code) + self.assertFalse(response.json()["unique"]) @pytest.mark.django_db def test_data_invalid(self): """Check CheckUniqueView view raises 422 code when passed data is invalid""" response = self.client.post(self.url, {"prop": "invalid", "value": "user"}) - self.assertEqual(422, response.status_code) + self.assertEqual(status.HTTP_422_UNPROCESSABLE_ENTITY, response.status_code) class TestRegisterView(APITestCase): @@ -309,8 +310,8 @@ def test_request_otp_on_email(self): self.url, {"destination": "email@django.com", "email": "email@django.com"} ) - self.assertEqual(201, response.status_code) - self.assertEqual("Message sent successfully!", response.json()["message"]) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.json()["message"], "Email Message sent successfully!") @pytest.mark.django_db def test_request_otp_on_email_and_mobile(self): @@ -320,26 +321,36 @@ def test_request_otp_on_email_and_mobile(self): """ response = self.client.post( - self.url, {"destination": 1231242492, "email": "email@django.com"} + self.url, {"destination": 9999999999, "email": "email@django.com"} ) - self.assertEqual(201, response.status_code) - self.assertEqual("Message sent successfully!", response.json()["message"]) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.json()["message"], "Email Message sent successfully!") + self.assertEqual( + response.json()["mobile_message"], "Mobile Message sent successfully!" + ) @pytest.mark.django_db - def test_raise_api_exception_when_email_invalid(self): - """Checks OTPView raises validation error when email/mobile is invalid""" + def test_raise_api_exception_when_destination_as_mobile_is_invalid(self): + """Checks OTPView raises validation error when mobile is invalid""" response = self.client.post( self.url, {"destination": "a.b", "email": "abc@d.com"} ) - - self.assertEqual(500, response.status_code) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( - "Server configuration error occurred: Invalid recipient.", response.json()["detail"], + "OTP sending failed! because ['Invalid Mobile Number']", ) + @pytest.mark.django_db + def test_raise_api_exception_when_email_is_invalid(self): + """Checks OTPView raises validation error when email is invalid""" + + response = self.client.post(self.url, {"destination": "abc", "email": "abc"}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.json()["email"], ["Enter a valid email address."]) + @pytest.mark.django_db def test_raise_validation_error_when_email_not_response_when_user_is_new(self): """ @@ -350,10 +361,10 @@ def test_raise_validation_error_when_email_not_response_when_user_is_new(self): response = self.client.post(self.url, {"destination": "email@django.com"}) self.assertEqual( - ["email field is compulsory while verifying a non-existing user's OTP."], response.json()["non_field_errors"], + ["Email field is compulsory while verifying a non-existing user's OTP."], ) - self.assertEqual(400, response.status_code) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @pytest.mark.django_db def test_raise_validation_error_when_is_login_response_when_user_is_new(self): @@ -410,7 +421,7 @@ def setUp(self) -> None: "drf_user.User", username="my_user", email="my_user@django.com", - mobile=2848482848, + mobile=9848482848, ) # create otp of registered user self.user_otp = baker.make( @@ -424,7 +435,7 @@ def setUp(self) -> None: self.data = { "name": "random_name", "email": "random@django.com", - "mobile": 1234567890, + "mobile": 7634567890, } self.data_with_incorrect_email_mobile = { "name": "name", @@ -434,37 +445,37 @@ def setUp(self) -> None: self.data_with_correct_otp = { "name": "random_name", "email": "random@django.com", - "mobile": 1234567890, + "mobile": 9234567890, "verify_otp": 888383, } self.data_with_incorrect_otp = { "name": "random_name", "email": "random@django.com", - "mobile": 1234567890, + "mobile": 7334567890, "verify_otp": 999999, } self.data_registered_user = { "name": "my_user", "email": "my_user@django.com", - "mobile": 2848482848, + "mobile": 6848482848, "verify_otp": 437474, } self.data_registered_user_with_different_mobile = { "name": "my_user", "email": "my_user@django.com", - "mobile": 2846482848, + "mobile": 7846482848, "verify_otp": 437474, } self.data_registered_user_with_different_email = { "name": "my_user", "email": "ser@django.com", - "mobile": 2848482848, + "mobile": 6848482848, "verify_otp": 437474, } self.data_random_user = { "name": "test_user1", "email": "test_user1@django.com", - "mobile": 2848444448, + "mobile": 8848444448, "verify_otp": 585858, } @@ -565,7 +576,7 @@ def test_login_with_correct_otp_for_new_user(self): self.url, data=self.data_with_correct_otp, format="json" ) - self.assertEqual(202, response.status_code) + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) self.assertContains(text="token", response=response, status_code=202) self.assertTrue(User.objects.get(email="random@django.com"))