diff --git a/drf_user/__init__.py b/drf_user/__init__.py index 5275fa6..0d64da2 100644 --- a/drf_user/__init__.py +++ b/drf_user/__init__.py @@ -41,8 +41,6 @@ def update_user_settings() -> dict: Returns ------- user_settings: dict - - Author: Himanshu Shankar (https://himanshus.com) """ custom_settings = getattr(settings, "USER_SETTINGS", None) diff --git a/drf_user/admin.py b/drf_user/admin.py index 272e4a0..9876e7d 100644 --- a/drf_user/admin.py +++ b/drf_user/admin.py @@ -1,7 +1,5 @@ """ All Admin configuration related to drf_user - -Author: Himanshu Shankar (https://himanshus.com) """ from django.contrib import admin from django.contrib.auth.admin import Group @@ -19,8 +17,6 @@ class DRFUserAdmin(UserAdmin): """ Overrides UserAdmin to show fields name & mobile and remove fields: first_name, last_name - - Author: Himanshu Shankar (https://himanshus.com) """ fieldsets = ( diff --git a/drf_user/apps.py b/drf_user/apps.py index 2922304..8127dbb 100644 --- a/drf_user/apps.py +++ b/drf_user/apps.py @@ -13,10 +13,6 @@ def ready(self): Register signals Call update_user_settings() to update the user setting as per django configurations - Returns - ------- - - Author: Himanshu Shankar (https://himanshus.com) """ from . import update_user_settings diff --git a/drf_user/auth.py b/drf_user/auth.py index a73a4f3..534580d 100644 --- a/drf_user/auth.py +++ b/drf_user/auth.py @@ -1,12 +1,14 @@ """ Custom backends to facilitate authorizations - -Author: Himanshu Shankar (https://himanshus.com) """ import re +from typing import Optional from django.contrib.auth import get_user_model from django.contrib.auth.backends import ModelBackend +from django.http import HttpRequest + +from drf_user.models import User class MultiFieldModelBackend(ModelBackend): @@ -17,7 +19,9 @@ class MultiFieldModelBackend(ModelBackend): user_model = get_user_model() - def authenticate(self, request, username=None, password=None, **kwargs) -> None: + def authenticate( + self, request: HttpRequest, username: str = None, password: str = None, **kwargs + ) -> Optional[User]: """ This function is used to authenticate a user. User can send either of email, mobile or username in request to @@ -57,7 +61,7 @@ def authenticate(self, request, username=None, password=None, **kwargs) -> None: except self.user_model.DoesNotExist: return None - def get_user(self, username: int) -> None: + def get_user(self, username: int) -> Optional[User]: """Returns user object if exists otherwise None Parameters diff --git a/drf_user/google_auth.py b/drf_user/google_auth.py new file mode 100644 index 0000000..b92aea6 --- /dev/null +++ b/drf_user/google_auth.py @@ -0,0 +1,52 @@ +"""Helper methods related to google authentication""" +from typing import Any +from typing import Dict + +import requests +from django.conf import settings +from rest_framework.exceptions import ValidationError + +from drf_user.variables import GOOGLE_ACCESS_TOKEN_OBTAIN_URL +from drf_user.variables import GOOGLE_AUTHORIZATION_CODE +from drf_user.variables import GOOGLE_USER_INFO_URL + + +def google_get_access_token(*, code: str, redirect_uri: str) -> str: + """This method get access token from google API""" + # Reference: https://developers.google.com/identity/protocols/oauth2/web-server#obtainingaccesstokens # NOQA + google_client_id: str = settings.GOOGLE_OAUTH2_CLIENT_ID + google_client_secret: str = settings.GOOGLE_OAUTH2_CLIENT_SECRET + if not (google_client_id and google_client_secret): + raise ValueError( + "GOOGLE_OAUTH2_CLIENT_ID and GOOGLE_OAUTH2_CLIENT_SECRET must be set in your settings file." # NOQA + ) + + data = { + "code": code, + "client_id": google_client_id, + "client_secret": google_client_secret, + "redirect_uri": redirect_uri, + "grant_type": GOOGLE_AUTHORIZATION_CODE, + } + + response = requests.post(GOOGLE_ACCESS_TOKEN_OBTAIN_URL, data=data) + + if not response.ok: + raise ValidationError( + f"Failed to obtain access token from Google. {response.json()}" + ) + + return response.json()["access_token"] + + +def google_get_user_info(*, access_token: str) -> Dict[str, Any]: + """This method gives us the user info using google's access token.""" + # Reference: https://developers.google.com/identity/protocols/oauth2/web-server#callinganapi # NOQA + response = requests.get(GOOGLE_USER_INFO_URL, params={"access_token": access_token}) + + if not response.ok: + raise ValidationError( + f"Failed to obtain user info from Google. {response.json()}" + ) + + return response.json() diff --git a/drf_user/managers.py b/drf_user/managers.py index b06ae0a..2751f90 100644 --- a/drf_user/managers.py +++ b/drf_user/managers.py @@ -9,8 +9,6 @@ class UserManager(BaseUserManager): """ UserManager class for Custom User Model - - Author: Himanshu Shankar (https://himanshus.com) Source: Can't find link but the following solution is inspired from a solution provided on internet. """ @@ -21,8 +19,8 @@ def _create_user( self, username: str, email: str, - password: str, - fullname: str, + name: str, + password: Optional[str] = None, mobile: Optional[str] = None, **kwargs ): @@ -31,9 +29,12 @@ def _create_user( """ email = self.normalize_email(email) user = self.model( - username=username, email=email, name=fullname, mobile=mobile, **kwargs + username=username, email=email, name=name, mobile=mobile, **kwargs ) - user.set_password(password) + if password: + user.set_password(password) + else: + user.set_unusable_password() user.save(using=self._db) return user @@ -41,8 +42,8 @@ def create_user( self, username: str, email: str, - password: str, name: str, + password: Optional[str] = None, mobile: Optional[str] = None, **kwargs ): @@ -69,7 +70,14 @@ def create_user( kwargs.setdefault("is_staff", False) kwargs.setdefault("is_active", vals.get("DEFAULT_ACTIVE_STATE", False)) - return self._create_user(username, email, password, name, mobile, **kwargs) + return self._create_user( + username=username, + email=email, + name=name, + password=password, + mobile=mobile, + **kwargs + ) def create_superuser( self, @@ -83,6 +91,7 @@ def create_superuser( """ Creates a super user considering the specified user settings from Django Project's settings.py + Parameters ---------- username: str @@ -108,4 +117,11 @@ def create_superuser( if kwargs.get("is_staff") is not True: raise ValueError("Superuser must have is_staff=True.") - return self._create_user(username, email, password, name, mobile, **kwargs) + return self._create_user( + username=username, + email=email, + name=name, + password=password, + mobile=mobile, + **kwargs + ) diff --git a/drf_user/mixins.py b/drf_user/mixins.py new file mode 100644 index 0000000..05d2496 --- /dev/null +++ b/drf_user/mixins.py @@ -0,0 +1,16 @@ +"""Helper Mixins""" +from rest_framework.permissions import AllowAny +from rest_framework.permissions import IsAuthenticated + + +class AuthAPIMixin: + """Mixin for Authenticated APIs""" + + permission_classes = (IsAuthenticated,) + + +class PublicAPIMixin: + """Mixin for Public APIs""" + + authentication_classes = () + permission_classes = (AllowAny,) diff --git a/drf_user/models.py b/drf_user/models.py index ff9bfe7..facfc30 100644 --- a/drf_user/models.py +++ b/drf_user/models.py @@ -30,8 +30,6 @@ class User(AbstractBaseUser, PermissionsMixin): mobile: Mobile Number of the user name: Name of the user. Replaces last_name & first_name update_date: DateTime instance when the user was updated - - Author: Himanshu Shankar (https://himanshus.com) """ username = models.CharField( @@ -93,8 +91,6 @@ class AuthTransaction(models.Model): """ Represents all authentication in the system that took place via REST API. - - Author: Himanshu Shankar (https://himanshus.com) """ ip_address = models.GenericIPAddressField(blank=False, null=False) @@ -130,8 +126,6 @@ class Meta: class OTPValidation(models.Model): """ Represents all OTP Validation in the System. - - Author: Himanshu Shankar (https://himanshus.com) """ otp = models.CharField(verbose_name=_("OTP Code"), max_length=10) diff --git a/drf_user/serializers.py b/drf_user/serializers.py index 0103159..186d099 100644 --- a/drf_user/serializers.py +++ b/drf_user/serializers.py @@ -1,4 +1,7 @@ """Serializers related to drf-user""" +from typing import Dict +from typing import Optional + from django.contrib.auth.password_validation import validate_password from django.core.validators import EmailValidator from django.core.validators import ValidationError @@ -40,7 +43,7 @@ def validate_email(self, value: str) -> str: return value else: raise serializers.ValidationError( - "The email must be " "pre-validated via OTP." + "The email must be pre-validated via OTP." ) def validate_mobile(self, value: str) -> str: @@ -133,8 +136,6 @@ class OTPSerializer(serializers.Serializer): >>> OTPSerializer(data={"destination": "88xx6xx5xx", >>> "email": "me@himanshus.com", >>> "verify_otp": 2930433, "is_login": True}) - - Author: Himanshu Shankar (https://himanshus.com) """ email = serializers.EmailField(required=False) @@ -142,7 +143,7 @@ class OTPSerializer(serializers.Serializer): verify_otp = serializers.CharField(required=False) destination = serializers.CharField(required=True) - def get_user(self, prop: str, destination: str) -> User: + def get_user(self, prop: str, destination: str) -> Optional[User]: """ Provides current user on the basis of property and destination provided. @@ -170,7 +171,7 @@ def get_user(self, prop: str, destination: str) -> User: return user - def validate(self, attrs: dict) -> dict: + def validate(self, attrs: Dict) -> Dict: """ Performs custom validation to check if any user exists with provided details. @@ -243,7 +244,7 @@ class OTPLoginRegisterSerializer(serializers.Serializer): mobile = serializers.CharField(required=True) @staticmethod - def get_user(email: str, mobile: str): + def get_user(email: str, mobile: str) -> Optional[User]: """Fetches user object""" try: user = User.objects.get(email=email) @@ -272,7 +273,7 @@ def get_user(email: str, mobile: str): ) return user - def validate(self, attrs: dict) -> dict: + def validate(self, attrs: Dict) -> Dict: """Validates the response""" attrs["user"] = self.get_user( @@ -294,7 +295,7 @@ class PasswordResetSerializer(serializers.Serializer): email = serializers.EmailField(required=True) password = serializers.CharField(required=True) - def get_user(self, destination: str) -> User: + def get_user(self, destination: str) -> Optional[User]: """Provides current user on the basis of property and destination provided. @@ -313,7 +314,7 @@ def get_user(self, destination: str) -> User: return user - def validate(self, attrs: dict) -> dict: + def validate(self, attrs: Dict) -> Dict: """Performs custom validation to check if any user exists with provided email. @@ -368,7 +369,7 @@ class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): } @classmethod - def get_token(cls, user): + def get_token(cls, user: User) -> str: """Generate token, then add extra data to the token.""" token = super().get_token(user) @@ -383,3 +384,18 @@ def get_token(cls, user): token["name"] = user.name return token + + +class GoogleLoginSerializer(serializers.Serializer): + """Google Login Serializer + + Serializer to handle google oauth2 callback + Params + code: If the Google OAuth2 was successful, + Google will call our callback API with a code GET parameter. + error: If the Google OAuth2 was not successful, + Google will call our API with an error GET parameter. + """ + + code = serializers.CharField(required=False) + error = serializers.CharField(required=False) diff --git a/drf_user/urls.py b/drf_user/urls.py index 732a65b..1ef3732 100644 --- a/drf_user/urls.py +++ b/drf_user/urls.py @@ -32,4 +32,5 @@ path( "refresh-token/", views.CustomTokenRefreshView.as_view(), name="refresh_token" ), + path("google/", views.GoogleLoginView.as_view(), name="login-with-google"), ] diff --git a/drf_user/utils.py b/drf_user/utils.py index cd75b13..ff241cc 100644 --- a/drf_user/utils.py +++ b/drf_user/utils.py @@ -1,7 +1,10 @@ """Collection of general helper functions.""" import datetime +from typing import Dict +from typing import Optional import pytz +from django.db import transaction from django.http import HttpRequest from django.utils import timezone from django.utils.text import gettext_lazy as _ @@ -23,7 +26,7 @@ otp_settings = user_settings["OTP"] -def datetime_passed_now(source): +def datetime_passed_now(source) -> 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. @@ -34,8 +37,6 @@ def datetime_passed_now(source): Returns ------- 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) @@ -43,7 +44,7 @@ def datetime_passed_now(source): return source <= datetime.datetime.now() -def check_unique(prop, value): +def check_unique(prop, value) -> bool: """ This function checks if the value provided is present in Database or can be created in DBMS as unique data. @@ -72,7 +73,7 @@ def check_unique(prop, value): return user.count() == 0 -def generate_otp(prop, value): +def generate_otp(prop, value) -> OTPValidation: """ This function generates an OTP and saves it into Model. It also sets various counters, such as send_counter, @@ -164,19 +165,14 @@ def send_otp(value, otpobj, recip): ) message = ( - "OTP for verifying " - + otpobj.get_prop_display() - + ": " - + value - + " is " - + otp - + ". Don't share this with anyone!" + f"OTP for verifying {otpobj.get_prop_display()}: {value} is {otp}." + f" Don't share this with anyone!" ) try: rdata = send_message(message, otp_settings["SUBJECT"], [value], [recip]) except ValueError as err: - raise APIException(_("Server configuration error occured: %s") % str(err)) + raise APIException(_(f"Server configuration error occurred: {err}")) otpobj.reactive_at = timezone.now() + datetime.timedelta( minutes=otp_settings["COOLING_PERIOD"] @@ -186,7 +182,7 @@ def send_otp(value, otpobj, recip): return rdata -def login_user(user: User, request: HttpRequest) -> dict: +def login_user(user: User, request: HttpRequest) -> Dict: """ This function is used to login a user. It saves the authentication in AuthTransaction model. @@ -232,7 +228,7 @@ def login_user(user: User, request: HttpRequest) -> dict: } -def check_validation(value): +def check_validation(value) -> bool: """ This functions check if given value is already validated via OTP or not. Parameters @@ -258,7 +254,7 @@ def check_validation(value): return False -def validate_otp(value, otp): +def validate_otp(value, otp) -> Optional[bool]: """ This function is used to validate the OTP for a particular value. It also reduces the attempt count by 1 and resets OTP. @@ -288,7 +284,7 @@ def validate_otp(value, otp): elif otp_object.validate_attempt <= 0: generate_otp(otp_object.prop, value) raise AuthenticationFailed( - detail=_("Incorrect OTP. Attempt exceeded! OTP has been " "reset.") + detail=_("Incorrect OTP. Attempt exceeded! OTP has been reset.") ) else: @@ -308,3 +304,14 @@ def validate_otp(value, otp): "destination. Kindly send an OTP first" ) ) + + +@transaction.atomic +def get_or_create_user(*, email: str, **extra_data) -> User: + """Check if user exists or not. Create user if not exists.""" + user = User.objects.filter(email=email).first() + + if user: + return user + + return User.objects.create_user(email=email, **extra_data) diff --git a/drf_user/variables.py b/drf_user/variables.py index baa231a..8a69977 100644 --- a/drf_user/variables.py +++ b/drf_user/variables.py @@ -1,10 +1,17 @@ """ All static variables used in the system. - -Author: Himanshu Shankar (https://himanshus.com) -Author: Aditya Gupta (https://github.com/ag93999) """ +from typing import List +from typing import Tuple + +EMAIL: str = "E" +MOBILE: str = "M" +DESTINATION_CHOICES: List[Tuple[str, str]] = [ + (EMAIL, "EMail Address"), + (MOBILE, "Mobile Number"), +] -EMAIL = "E" -MOBILE = "M" -DESTINATION_CHOICES = [(EMAIL, "EMail Address"), (MOBILE, "Mobile Number")] +GOOGLE_ID_TOKEN_INFO_URL: str = "https://www.googleapis.com/oauth2/v3/tokeninfo" +GOOGLE_ACCESS_TOKEN_OBTAIN_URL: str = "https://oauth2.googleapis.com/token" +GOOGLE_USER_INFO_URL: str = "https://www.googleapis.com/oauth2/v3/userinfo" +GOOGLE_AUTHORIZATION_CODE: str = "authorization_code" diff --git a/drf_user/views.py b/drf_user/views.py index ca54361..3ace0f5 100644 --- a/drf_user/views.py +++ b/drf_user/views.py @@ -1,5 +1,8 @@ """Views for drf-user""" +from urllib.parse import urlencode + from django.conf import settings +from django.shortcuts import redirect from django.utils import timezone from django.utils.text import gettext_lazy as _ from drfaddons.utils import get_client_ip @@ -10,8 +13,6 @@ from rest_framework.generics import CreateAPIView from rest_framework.generics import RetrieveUpdateAPIView from rest_framework.parsers import JSONParser -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 @@ -20,16 +21,22 @@ from rest_framework_simplejwt.settings import api_settings from rest_framework_simplejwt.views import TokenRefreshView +from drf_user.google_auth import google_get_access_token +from drf_user.google_auth import google_get_user_info +from drf_user.mixins import AuthAPIMixin +from drf_user.mixins import PublicAPIMixin 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 GoogleLoginSerializer 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_or_create_user from drf_user.utils import login_user from drf_user.utils import send_otp from drf_user.utils import validate_otp @@ -37,19 +44,15 @@ from drf_user.variables import MOBILE -class RegisterView(CreateAPIView): +class RegisterView(PublicAPIMixin, CreateAPIView): """ Register View Register a new user to the system. The data required are username, email, name, password and mobile (optional). - - Author: Himanshu Shankar (https://himanshus.com) - Aditya Gupta (https://github.com/ag93999) """ renderer_classes = (JSONRenderer,) - permission_classes = (AllowAny,) serializer_class = UserSerializer def perform_create(self, serializer): @@ -68,7 +71,7 @@ def perform_create(self, serializer): return User.objects.create_user(**data) -class LoginView(APIView): +class LoginView(PublicAPIMixin, APIView): """ Login View @@ -77,13 +80,9 @@ class LoginView(APIView): username -- Either username or mobile or email address. password -- Password of the user. - - Author: Himanshu Shankar (https://himanshus.com) - Aditya Gupta (https://github.com/ag93999) """ renderer_classes = (JSONRenderer,) - permission_classes = (AllowAny,) serializer_class = CustomTokenObtainPairSerializer def post(self, request, *args, **kwargs): @@ -118,7 +117,7 @@ def post(self, request, *args, **kwargs): return Response(resp, status=status.HTTP_200_OK) -class CheckUniqueView(APIView): +class CheckUniqueView(PublicAPIMixin, APIView): """ Check Unique API View @@ -126,13 +125,9 @@ class CheckUniqueView(APIView): doesn't exists yet) 'prop' -- A property to check for uniqueness (username/email/mobile) 'value' -- Value against property which is to be checked for. - - Author: Himanshu Shankar (https://himanshus.com) - Aditya Gupta (https://github.com/ag93999) """ renderer_classes = (JSONRenderer,) - permission_classes = (AllowAny,) serializer_class = CheckUniqueSerializer def validated(self, serialized_data, *args, **kwargs): @@ -158,7 +153,7 @@ def post(self, request): ) -class OTPView(APIView): +class OTPView(PublicAPIMixin, APIView): """ OTP Validate | OTP Login @@ -187,12 +182,8 @@ class OTPView(APIView): >>> {"destination": "me@himanshus.com", "is_login": True, >>> "verify_otp": 1234232} - - Author: Himanshu Shankar (https://himanshus.com) - Aditya Gupta (https://github.com/ag93999) """ - permission_classes = (AllowAny,) serializer_class = OTPSerializer def post(self, request, *args, **kwargs): @@ -236,21 +227,17 @@ def post(self, request, *args, **kwargs): ) -class RetrieveUpdateUserAccountView(RetrieveUpdateAPIView): +class RetrieveUpdateUserAccountView(AuthAPIMixin, RetrieveUpdateAPIView): """ Retrieve Update User Account View get: Fetch Account Details put: Update all details patch: Update some details - - Author: Himanshu Shankar (https://himanshus.com) - Aditya Gupta( https://github.com/ag93999) """ queryset = User.objects.all() serializer_class = UserSerializer - permission_classes = (IsAuthenticated,) lookup_field = "created_by" def get_object(self): @@ -270,7 +257,7 @@ def update(self, request, *args, **kwargs): ) -class OTPLoginView(APIView): +class OTPLoginView(PublicAPIMixin, APIView): """ OTP Login View @@ -285,12 +272,8 @@ class OTPLoginView(APIView): email -- Required mobile -- Required verify_otp -- Not Required (only when verifying OTP) - - Author: Himanshu Shankar (https://himanshus.com) - Aditya Gupta (https://github.com/ag93999) """ - permission_classes = (AllowAny,) renderer_classes = (JSONRenderer,) parser_classes = (JSONParser,) serializer_class = OTPLoginRegisterSerializer @@ -363,15 +346,13 @@ def post(self, request, *args, **kwargs): return Response(data=message, status=curr_status) -class PasswordResetView(APIView): +class PasswordResetView(PublicAPIMixin, APIView): """This API can be used to reset a user's password. Usage: First send an otp to the user by making an API call to `api/user/otp/` with `is_login` parameter value false. """ - permission_classes = (AllowAny,) - def post(self, request, *args, **kwargs): """Overrides post method to validate OTP and reset password""" serializer = PasswordResetSerializer(data=request.data) @@ -391,21 +372,18 @@ def post(self, request, *args, **kwargs): ) -class UploadImageView(APIView): +class UploadImageView(AuthAPIMixin, APIView): """This API can be used to upload a profile picture for user. usage: Create a multipart request to this API, with your image 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,) parser_class = (MultiPartParser,) def post(self, request, *args, **kwargs): @@ -458,3 +436,51 @@ def post(self, request, *args, **kwargs): auth_transaction.save(update_fields=["token", "expires_at"]) return Response({"token": str(token)}, status=status.HTTP_200_OK) + + +# Google Auth API View +class GoogleLoginView(PublicAPIMixin, APIView): + """Google Login View + + Implements Google Oauth2 Login View + """ + + def get(self, request, *args, **kwargs): + """Google Login View""" + serializer = GoogleLoginSerializer(data=request.GET) + serializer.is_valid(raise_exception=True) + + validated_data = serializer.validated_data + + code = validated_data.get("code") + error = validated_data.get("error") + + # set login url to redirect if google auth fails + login_url = f"{settings.FRONTEND_LOGIN_URL}" + if not login_url: + raise ValueError("FRONTEND_LOGIN_URL must be set in your settings file.") + + if error or not code: + params = urlencode({"error": error}) + return redirect(f"{login_url}?{params}") + + # set redirect uri + redirect_uri = settings.REDIRECT_URI + if not redirect_uri: + raise ValueError("REDIRECT_URI must be set in your settings file.") + + access_token = google_get_access_token(code=code, redirect_uri=redirect_uri) + + user_data = google_get_user_info(access_token=access_token) + + profile_data = { + "username": user_data["email"], + "email": user_data["email"], + "name": f"{user_data.get('givenName', '')} {user_data.get('familyName', '')}", # NOQA + } + + # if user exists with google email then return user otherwise create one + user = get_or_create_user(**profile_data) + + # return jwt token + return Response(login_user(user, request), status=status.HTTP_200_OK) diff --git a/tests/test_views.py b/tests/test_views.py index de7de33..02952fb 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -336,7 +336,7 @@ def test_raise_api_exception_when_email_invalid(self): self.assertEqual(500, response.status_code) self.assertEqual( - "Server configuration error occured: Invalid recipient.", + "Server configuration error occurred: Invalid recipient.", response.json()["detail"], ) @@ -584,7 +584,7 @@ def test_login_with_incorrect_email_mobile(self): # when drf_addons is updated self.assertEqual(500, response.status_code) self.assertEqual( - "Server configuration error occured: Invalid recipient.", + "Server configuration error occurred: Invalid recipient.", response.json()["detail"], )