FEATURE(account): adding InviteRequestView

adding InviteRequestView
This commit is contained in:
Haydar Ghasemi 2026-07-22 17:32:30 +03:30
parent e6ea563d19
commit 86b02ffa73
5 changed files with 165 additions and 7 deletions

View file

@ -21,6 +21,8 @@ from .provinces_and_cities import state
from apps.users.constans import MAX_OTP_TRY, DEVELOPMENT_PHONE_NUMBERS, GenderChoices, EducationDegreeChoices, \ from apps.users.constans import MAX_OTP_TRY, DEVELOPMENT_PHONE_NUMBERS, GenderChoices, EducationDegreeChoices, \
MAX_OTP_VALID_DURATION MAX_OTP_VALID_DURATION
from oauth2_provider import settings as oauth2_settings
from utils.models import BaseModel
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -267,3 +269,28 @@ class User(AbstractUser):
def __str__(self): def __str__(self):
return self.name or self.get_full_name() or self.username or self.phone_number or self.email or _('no name') 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}'

View file

@ -2,7 +2,7 @@ from django.core.validators import RegexValidator
from oauth2_provider.models import get_access_token_model from oauth2_provider.models import get_access_token_model
from rest_framework import serializers 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 import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from unidecode import unidecode from unidecode import unidecode
@ -134,6 +134,34 @@ class ChangePasswordSerializer(serializers.Serializer):
new_password = serializers.CharField(required=True) 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() AccessToken = get_access_token_model()
class SessionSerializer(serializers.ModelSerializer): class SessionSerializer(serializers.ModelSerializer):
is_current = serializers.SerializerMethodField() is_current = serializers.SerializerMethodField()

View file

@ -14,7 +14,7 @@ from rest_framework.test import APIClient, APITestCase
from apps.core.models import Config from apps.core.models import Config
from apps.gooyal_oauth2.models import Scope 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() AccessToken = get_access_token_model()
Application = get_application_model() Application = get_application_model()
@ -149,6 +149,100 @@ class UserTests(APITestCase):
self.assertEqual(response.json()['results'][0]['is_current'] , True) 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)

View file

@ -2,7 +2,7 @@ from django.urls import path
from django.contrib.auth.views import LogoutView from django.contrib.auth.views import LogoutView
from .views import UserListView, UserPublicRetrieveView, AccountView, RequestOTPView, ChangePasswordView, \ from .views import UserListView, UserPublicRetrieveView, AccountView, RequestOTPView, ChangePasswordView, \
OTPLoginView, ProfileDetailView, ProfileUpdateView, RequestOTTView, UserCurrentAvatarUrlView, UserInquiryView, \ OTPLoginView, ProfileDetailView, ProfileUpdateView, RequestOTTView, UserCurrentAvatarUrlView, UserInquiryView, \
UserDetailedRetrieveView, UserSessionListView, UserProfileView UserDetailedRetrieveView, UserSessionListView, UserProfileView, InviteRequestView
app_name = "users" app_name = "users"
urlpatterns = [ urlpatterns = [
@ -21,5 +21,6 @@ urlpatterns = [
path('api/request_ott/', RequestOTTView.as_view(), name='request_ott_api'), path('api/request_ott/', RequestOTTView.as_view(), name='request_ott_api'),
path('api/change_password/', ChangePasswordView.as_view(), name='change_password_api'), path('api/change_password/', ChangePasswordView.as_view(), name='change_password_api'),
path('api/inquiry/', UserInquiryView.as_view(), name='user_inquiry_api'), path('api/inquiry/', UserInquiryView.as_view(), name='user_inquiry_api'),
path('api/invite_requests/', InviteRequestView.as_view(), name='invite_request_api'),
] ]

View file

@ -17,10 +17,9 @@ from rest_framework.views import APIView
from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements
from apps.users.filters import UserFilter from apps.users.filters import UserFilter
from apps.users.forms import OTPAuthenticationForm, ProfileUpdateForm from apps.users.forms import OTPAuthenticationForm, ProfileUpdateForm
from apps.users.models import User from apps.users.models import User, InviteRequest
from apps.users.provinces_and_cities import State
from apps.users.serializers import PublicUserSerializer, AccountSerializer, RequestOTPSerializer, RequestOTTSerializer, \ from apps.users.serializers import PublicUserSerializer, AccountSerializer, RequestOTPSerializer, RequestOTTSerializer, \
ChangePasswordSerializer, UserInquirySerializer, SessionSerializer ChangePasswordSerializer, UserInquirySerializer, SessionSerializer, InviteRequestSerializer
from utils.throttles import RequestOTPDayRateThrottle, RequestOTPMinRateThrottle, NumberedRequestOTPDayRateThrottle, NumberedRequestOTPMinRateThrottle from utils.throttles import RequestOTPDayRateThrottle, RequestOTPMinRateThrottle, NumberedRequestOTPDayRateThrottle, NumberedRequestOTPMinRateThrottle
UserModel = get_user_model() UserModel = get_user_model()
@ -128,6 +127,15 @@ class UserInquiryView(generics.CreateAPIView):
required_scopes = ['accounts.profile:inquiry'] 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): class ChangePasswordView(generics.UpdateAPIView):
permission_classes = [IsAuthenticatedOrTokenHasScope] permission_classes = [IsAuthenticatedOrTokenHasScope]
serializer_class = ChangePasswordSerializer serializer_class = ChangePasswordSerializer