initial commit

This commit is contained in:
2026-03-11 17:12:28 +08:00
commit 5d1e1cb7cb
61 changed files with 2971 additions and 0 deletions

View File

@@ -0,0 +1,131 @@
import logging
import random
import string
from django.contrib.auth import get_user_model
from django.db import transaction
from django.utils import timezone
from django_redis import get_redis_connection
from drf_spectacular.utils import extend_schema_serializer
from rest_framework import serializers
from core.serializers.base import BaseModelSerializer
from apps.users.tasks import send_verification_sms
from apps.users.utils import record_login_attempt
User = get_user_model()
logger = logging.getLogger(__name__)
class UserProfilePictureSerializer(BaseModelSerializer):
class Meta:
model = User
fields = BaseModelSerializer.Meta.fields + ("profile_picture",)
class UserListSerializer(BaseModelSerializer):
full_name = serializers.CharField(read_only=True)
class Meta:
model = User
fields = BaseModelSerializer.Meta.fields + (
"mobile",
"full_name",
"profile_picture",
)
class RegisterSerializer(serializers.Serializer):
mobile = serializers.CharField(max_length=11)
code = serializers.CharField(max_length=6)
password = serializers.CharField(write_only=True)
re_password = serializers.CharField(write_only=True)
first_name = serializers.CharField(max_length=100, required=False, allow_blank=True)
last_name = serializers.CharField(max_length=100, required=False, allow_blank=True)
def validate(self, data):
mobile = data.get("mobile", "")
password = data.get("password", "")
re_password = data.get("re_password", "")
if not (mobile.isdigit() and len(mobile) == 11):
raise serializers.ValidationError({"mobile": "فرمت شماره موبایل نادرست است."})
if password != re_password:
raise serializers.ValidationError({"password": "رمز عبور مطابقت ندارد."})
return data
@extend_schema_serializer(component_name="UsersSendOTP")
class SendOTPSerializer(serializers.Serializer):
mobile = serializers.CharField(max_length=11)
mode = serializers.ChoiceField(choices=["register", "login", "forget_password"])
def validate_mobile(self, value):
"""
Normalize and validate Iranian mobile numbers (example: 09XXXXXXXXX).
"""
if not value.isdigit() or len(value) != 11 or not value.startswith("09"):
raise serializers.ValidationError("شماره موبایل معتبر نیست.")
return value
@extend_schema_serializer(component_name="UsersLoginOtp")
class LoginOtpSerializer(serializers.Serializer):
mobile = serializers.CharField(max_length=11)
code = serializers.CharField(max_length=6)
def validate_mobile(self, value):
if not (value.isdigit() and len(value) == 11):
raise serializers.ValidationError("فرمت شماره موبایل نادرست است.")
return value
class LoginSerializer(serializers.Serializer):
mobile = serializers.CharField(max_length=11)
password = serializers.CharField(write_only=True)
def validate_mobile(self, value):
if not (value.isdigit() and len(value) == 11):
raise serializers.ValidationError("فرمت شماره موبایل نادرست است.")
return value
class ResetPasswordSerializer(serializers.Serializer):
mobile = serializers.CharField(max_length=11)
code = serializers.CharField(max_length=6)
password = serializers.CharField(write_only=True)
re_password = serializers.CharField(write_only=True)
def validate(self, data):
if data.get("password") != data.get("re_password"):
raise serializers.ValidationError({"password": "رمز عبور مطابقت ندارد."})
return data
class ChangePasswordSerializer(serializers.Serializer):
old_password = serializers.CharField(required=True, write_only=True)
new_password = serializers.CharField(required=True, write_only=True)
re_password = serializers.CharField(required=True, write_only=True)
def validate(self, data):
if data.get("new_password") != data.get("re_password"):
raise serializers.ValidationError({"new_password": "رمز عبور جدید و تکرار آن مطابقت ندارند."})
return data
class LogoutSerializer(serializers.Serializer):
refresh = serializers.CharField()
class TokenPairSerializer(serializers.Serializer):
access = serializers.CharField()
refresh = serializers.CharField()
class RegisterWithPasswordSerializer(serializers.Serializer):
mobile = serializers.CharField()
password = serializers.CharField()

21
apps/users/api/urls.py Normal file
View File

@@ -0,0 +1,21 @@
from django.urls import path
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from apps.users.api import views
app_name = "users"
urlpatterns = [
path("register/", views.RegisterWithOTPView.as_view(), name="register_verify"),
path("otp/send/", views.SendOTPView.as_view(), name="send_otp"),
path("otp/login/", views.LoginOTPView.as_view(), name="login_otp"),
path("login/", views.LoginView.as_view(), name="login"),
path("logout/", views.LogoutView.as_view(), name="logout"),
path("password/set/", views.SetPasswordView.as_view(), name="set_password"),
path("password/reset/", views.ResetPasswordView.as_view(), name="reset_password"),
path("password/change/", views.ChangePasswordView.as_view(), name="change_password"),
path("profile/picture/", views.ProfilePictureView.as_view(), name="profile_picture"),
path("list/", views.UserListView.as_view(), name="user_list"),
path("token/obtain/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
]

237
apps/users/api/views.py Normal file
View File

@@ -0,0 +1,237 @@
from django.contrib.auth import get_user_model
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema, inline_serializer
from rest_framework import serializers, status
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.generics import ListAPIView, UpdateAPIView
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework_simplejwt.tokens import RefreshToken
from core.paginations.limit_offset import CustomLimitOffsetPagination
from apps.users.api.serializers import (
ChangePasswordSerializer,
LoginOtpSerializer,
LoginSerializer,
RegisterSerializer,
ResetPasswordSerializer,
SendOTPSerializer,
UserListSerializer,
UserProfilePictureSerializer,
LogoutSerializer,
TokenPairSerializer,
RegisterWithPasswordSerializer,
)
from apps.users.services.auth import (
register_user_with_password,
register_user_with_otp,
generate_and_send_otp,
login_with_password,
login_with_otp,
reset_password_with_otp,
change_password,
logout_user
)
User = get_user_model()
class RegisterWithPasswordView(APIView):
"""
Sign-up with mobile and password
"""
permission_classes = (AllowAny,)
@extend_schema(request=RegisterWithPasswordSerializer, responses=TokenPairSerializer)
def post(self, request):
mobile = request.data.get("mobile")
password = request.data.get("password")
if not mobile or not password:
return Response(
{"detail": "mobile and password required"},
status=status.HTTP_400_BAD_REQUEST,
)
tokens = register_user_with_password(mobile, password)
return Response(tokens, status=status.HTTP_201_CREATED)
class RegisterWithOTPView(APIView):
"""
A view for registring with OTP and issuing JWT tokens.
"""
permission_classes = (AllowAny,)
@extend_schema(request=RegisterSerializer, responses=TokenPairSerializer)
def post(self, request):
serializer = RegisterSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data.copy()
data.pop("re_password", None)
tokens = register_user_with_otp(**data)
return Response(tokens, status=status.HTTP_201_CREATED)
class SendOTPView(APIView):
"""
send OTP code for on of the following actions:
+ registrations
+ login
+ password reset
"""
permission_classes = (AllowAny,)
@extend_schema(request=SendOTPSerializer, responses=None)
def post(self, request):
serializer = SendOTPSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
generate_and_send_otp(
mobile=serializer.validated_data["mobile"],
mode=serializer.validated_data["mode"]
)
return Response({"detail": "OTP sent successfully"}, status=status.HTTP_200_OK)
class LoginView(APIView):
permission_classes = (AllowAny,)
@extend_schema(request=LoginSerializer, responses=TokenPairSerializer)
def post(self, request):
serializer = LoginSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
tokens = login_with_password(
mobile=serializer.validated_data["mobile"],
password=serializer.validated_data["password"],
request=request
)
return Response(tokens, status=status.HTTP_200_OK)
class LoginOTPView(APIView):
permission_classes = (AllowAny,)
@extend_schema(request=LoginOtpSerializer, responses=TokenPairSerializer)
def post(self, request):
serializer = LoginOtpSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
tokens = login_with_otp(
mobile=serializer.validated_data["mobile"],
code=serializer.validated_data["code"],
request=request
)
return Response(tokens, status=status.HTTP_200_OK)
class ResetPasswordView(APIView):
permission_classes = (AllowAny,)
serializer_class = ResetPasswordSerializer
@extend_schema(request=ResetPasswordSerializer)
def post(self, request):
serializer = ResetPasswordSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
reset_password_with_otp(
mobile=serializer.validated_data["mobile"],
code=serializer.validated_data["code"],
password=serializer.validated_data["password"]
)
return Response({"detail": "رمز عبور با موفقیت تغییر کرد."}, status=status.HTTP_200_OK)
class ChangePasswordView(APIView):
permission_classes = [IsAuthenticated]
serializer_class = ChangePasswordSerializer
@extend_schema(request=ChangePasswordSerializer)
def patch(self, request, *args, **kwargs):
serializer = ChangePasswordSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
change_password(
user=request.user,
old_password=serializer.validated_data["old_password"],
new_password=serializer.validated_data["new_password"]
)
return Response({"detail": "رمز عبور با موفقیت تغییر کرد."}, status=status.HTTP_200_OK)
class LogoutView(APIView):
permission_classes = (IsAuthenticated,)
serializer_class = LogoutSerializer
@extend_schema(
request=inline_serializer(
name="LogoutRequest",
fields={"refresh": serializers.CharField()}
)
)
def post(self, request):
refresh_token = request.data.get("refresh")
logout_user(refresh_token)
return Response(status=status.HTTP_205_RESET_CONTENT)
class SetPasswordView(UpdateAPIView):
permission_classes = [IsAuthenticated]
authentication_classes = [JWTAuthentication]
serializer_class = ChangePasswordSerializer
@extend_schema(request=ChangePasswordSerializer, responses=None)
def patch(self, request, *args, **kwargs):
return super().patch(request, *args, **kwargs)
def get_object(self):
return self.request.user
class ProfilePictureView(APIView):
"""
Update the authenticated user's profile picture.
"""
permission_classes = [IsAuthenticated]
authentication_classes = [JWTAuthentication]
parser_classes = [MultiPartParser, FormParser]
@extend_schema(
request=UserProfilePictureSerializer,
responses=UserProfilePictureSerializer,
operation_id="users_profile_picture_self_create",
)
def post(self, request):
serializer = UserProfilePictureSerializer(
instance=request.user,
data=request.data,
context={"request": request},
partial=True,
)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
class UserListView(ListAPIView):
permission_classes = [IsAuthenticated]
authentication_classes = [JWTAuthentication]
serializer_class = UserListSerializer
queryset = User.objects.all()
pagination_class = CustomLimitOffsetPagination
filter_backends = (DjangoFilterBackend, OrderingFilter, SearchFilter)
search_fields = ("first_name", "last_name", "mobile")
ordering = ("-created_at",)
ordering_fields = ("first_name", "created_at")
@extend_schema(responses=UserListSerializer(many=True))
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)