diff --git a/apps/users/models.py b/apps/users/models.py index cc1ac4d..b5007d4 100644 --- a/apps/users/models.py +++ b/apps/users/models.py @@ -21,6 +21,8 @@ from .provinces_and_cities import state from apps.users.constans import MAX_OTP_TRY, DEVELOPMENT_PHONE_NUMBERS, GenderChoices, EducationDegreeChoices, \ MAX_OTP_VALID_DURATION +from oauth2_provider import settings as oauth2_settings +from utils.models import BaseModel logger = logging.getLogger(__name__) @@ -267,3 +269,28 @@ class User(AbstractUser): def __str__(self): return self.name or self.get_full_name() or self.username or self.phone_number or self.email or _('no name') + + +class InviteRequest(BaseModel): + inviter = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='invite_requests', + verbose_name=_('inviter'), + ) + mobile_number = models.CharField(_('mobile number'), max_length=30, db_index=True) + application = models.ForeignKey( + oauth2_settings.APPLICATION_MODEL, + on_delete=models.PROTECT, + related_name='invite_requests', + null=True, + blank=True + ) + + class Meta: + verbose_name = _('invite request') + verbose_name_plural = _('invite requests') + ordering = ['-created_at'] + + def __str__(self): + return f'{self.mobile_number} -> {self.inviter_id}' diff --git a/apps/users/serializers.py b/apps/users/serializers.py index a5f0cc2..5bdd513 100644 --- a/apps/users/serializers.py +++ b/apps/users/serializers.py @@ -2,7 +2,7 @@ from django.core.validators import RegexValidator from oauth2_provider.models import get_access_token_model from rest_framework import serializers -from apps.users.models import User +from apps.users.models import User, InviteRequest from django.utils import timezone from django.utils.translation import gettext_lazy as _ from unidecode import unidecode @@ -134,6 +134,34 @@ class ChangePasswordSerializer(serializers.Serializer): new_password = serializers.CharField(required=True) +class InviteRequestSerializer(serializers.ModelSerializer): + invite_code = serializers.UUIDField(write_only=True) + + class Meta: + model = InviteRequest + fields = ('uuid', 'invite_code', 'mobile_number', 'created_at') + read_only_fields = ('uuid', 'created_at') + + def validate_invite_code(self, value): + if not User.objects.filter(uuid=value).exists(): + raise serializers.ValidationError(_('Invalid invite code.')) + return value + + def validate_mobile_number(self, value): + value = unidecode(value) + phone_number_validator(value) + return value + + def create(self, validated_data): + invite_code = validated_data.pop('invite_code') + application = self.context['request'].auth + return InviteRequest.objects.create( + inviter_id=invite_code, + application=application, + **validated_data, + ) + + AccessToken = get_access_token_model() class SessionSerializer(serializers.ModelSerializer): is_current = serializers.SerializerMethodField() diff --git a/apps/users/tests.py b/apps/users/tests.py index 8fa80f6..5a1d4a7 100644 --- a/apps/users/tests.py +++ b/apps/users/tests.py @@ -14,7 +14,7 @@ from rest_framework.test import APIClient, APITestCase from apps.core.models import Config from apps.gooyal_oauth2.models import Scope -from apps.users.models import User +from apps.users.models import User, InviteRequest AccessToken = get_access_token_model() Application = get_application_model() @@ -149,6 +149,100 @@ class UserTests(APITestCase): self.assertEqual(response.json()['results'][0]['is_current'] , True) - +class InviteRequestTests(APITestCase): + user_uuid = uuid.UUID('c14e8b86-8f4a-44d9-b29d-badceb47005f') + inviter_uuid = uuid.UUID('d14e8b86-8f4a-44d9-b29d-badceb47005f') + application_uuid = uuid.UUID('e14e8b86-8f4a-44d9-b29d-badceb47005f') + client_id = 'inviteClientId1234567890123456789012' + client_secret = 'inviteClientSecret123456789012345678' + mobile_number = '+989120000000' + + client = APIClient() + + def setUp(self): + self.user = User.objects.create(pk=self.user_uuid, phone_number='+989100000001') + self.inviter = User.objects.create(pk=self.inviter_uuid, phone_number='+989100000002') + + Scope.objects.create(name='accounts.invite:submit', description='accounts.invite:submit') + + self.application = Application.objects.create( + name='invite-test-app', + client_id=self.client_id, + client_secret=self.client_secret, + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS, + hash_client_secret=False, + uuid=self.application_uuid, + user_id=self.user_uuid, + allowed_scope='accounts.invite:submit', + ) + + def basic_auth_header(self): + credentials = f'{self.client_id}:{self.client_secret}'.encode('utf-8') + return 'Basic ' + base64.b64encode(credentials).decode('utf-8') + + def test_create_invite_request_success(self): + response = self.client.post( + reverse('users:invite_request_api'), + data={ + 'invite_code': str(self.inviter_uuid), + 'mobile_number': self.mobile_number, + }, + format='json', + HTTP_AUTHORIZATION=self.basic_auth_header(), + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(InviteRequest.objects.count(), 1) + + invite_request = InviteRequest.objects.get() + self.assertEqual(invite_request.inviter_id, self.inviter_uuid) + self.assertEqual(invite_request.mobile_number, self.mobile_number) + self.assertEqual(invite_request.application_id, self.application_uuid) + + def test_create_invite_request_invalid_invite_code(self): + response = self.client.post( + reverse('users:invite_request_api'), + data={ + 'invite_code': str(uuid.uuid4()), + 'mobile_number': self.mobile_number, + }, + format='json', + HTTP_AUTHORIZATION=self.basic_auth_header(), + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(InviteRequest.objects.count(), 0) + + def test_create_invite_request_invalid_client_credentials(self): + response = self.client.post( + reverse('users:invite_request_api'), + data={ + 'invite_code': str(self.inviter_uuid), + 'mobile_number': self.mobile_number, + }, + format='json', + HTTP_AUTHORIZATION='Basic ' + base64.b64encode(b'wrong:credentials').decode('utf-8'), + ) + + self.assertEqual(response.status_code, 401) + self.assertEqual(InviteRequest.objects.count(), 0) + + def test_create_invite_request_missing_scope(self): + self.application.allowed_scope = '' + self.application.save(update_fields=['allowed_scope']) + + response = self.client.post( + reverse('users:invite_request_api'), + data={ + 'invite_code': str(self.inviter_uuid), + 'mobile_number': self.mobile_number, + }, + format='json', + HTTP_AUTHORIZATION=self.basic_auth_header(), + ) + + self.assertEqual(response.status_code, 403) + self.assertEqual(InviteRequest.objects.count(), 0) diff --git a/apps/users/urls.py b/apps/users/urls.py index 99aeea2..c11636b 100644 --- a/apps/users/urls.py +++ b/apps/users/urls.py @@ -2,7 +2,7 @@ from django.urls import path from django.contrib.auth.views import LogoutView from .views import UserListView, UserPublicRetrieveView, AccountView, RequestOTPView, ChangePasswordView, \ OTPLoginView, ProfileDetailView, ProfileUpdateView, RequestOTTView, UserCurrentAvatarUrlView, UserInquiryView, \ - UserDetailedRetrieveView, UserSessionListView, UserProfileView + UserDetailedRetrieveView, UserSessionListView, UserProfileView, InviteRequestView app_name = "users" urlpatterns = [ @@ -21,5 +21,6 @@ urlpatterns = [ path('api/request_ott/', RequestOTTView.as_view(), name='request_ott_api'), path('api/change_password/', ChangePasswordView.as_view(), name='change_password_api'), path('api/inquiry/', UserInquiryView.as_view(), name='user_inquiry_api'), + path('api/invite_requests/', InviteRequestView.as_view(), name='invite_request_api'), ] diff --git a/apps/users/views.py b/apps/users/views.py index 6485658..74fcb93 100644 --- a/apps/users/views.py +++ b/apps/users/views.py @@ -17,10 +17,9 @@ from rest_framework.views import APIView from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements from apps.users.filters import UserFilter from apps.users.forms import OTPAuthenticationForm, ProfileUpdateForm -from apps.users.models import User -from apps.users.provinces_and_cities import State +from apps.users.models import User, InviteRequest from apps.users.serializers import PublicUserSerializer, AccountSerializer, RequestOTPSerializer, RequestOTTSerializer, \ - ChangePasswordSerializer, UserInquirySerializer, SessionSerializer + ChangePasswordSerializer, UserInquirySerializer, SessionSerializer, InviteRequestSerializer from utils.throttles import RequestOTPDayRateThrottle, RequestOTPMinRateThrottle, NumberedRequestOTPDayRateThrottle, NumberedRequestOTPMinRateThrottle UserModel = get_user_model() @@ -128,6 +127,15 @@ class UserInquiryView(generics.CreateAPIView): required_scopes = ['accounts.profile:inquiry'] +class InviteRequestView(generics.CreateAPIView): + permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements] + required_scopes = ['accounts.invite:submit'] + required_alternate_scopes ={ + "POST": [["accounts.invite:submit"]], + } + serializer_class = InviteRequestSerializer + queryset = InviteRequest.objects.all() + class ChangePasswordView(generics.UpdateAPIView): permission_classes = [IsAuthenticatedOrTokenHasScope] serializer_class = ChangePasswordSerializer