from django.contrib.auth import get_user_model from django.http import HttpResponseRedirect 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.mixins import DestroyModelMixin, RetrieveModelMixin, UpdateModelMixin 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.viewsets import GenericViewSet from rest_framework_simplejwt.authentication import JWTAuthentication from apps.users.api.serializers import ( ChangePasswordSerializer, GoogleOAuthClaimVerifySerializer, GoogleOAuthCompleteSerializer, GoogleOAuthFlowSerializer, LoginOtpSerializer, LoginSerializer, LogoutSerializer, RegisterSerializer, RegisterWithPasswordSerializer, ResetPasswordSerializer, SendOTPSerializer, TokenPairSerializer, UserListSerializer, UserProfilePictureSerializer, UserProfileSerializer, UserSearchSerializer, ) from apps.users.api.throttles import ( GoogleClaimSendBurstThrottle, GoogleClaimSendSustainedThrottle, GoogleClaimVerifyThrottle, OTPLoginThrottle, OTPSendBurstThrottle, OTPSendSustainedThrottle, PasswordLoginThrottle, ) from apps.users.services.auth import ( change_password, generate_and_send_otp, login_with_otp, login_with_password, logout_user, register_user_with_otp, register_user_with_password, reset_password_with_otp, ) from apps.users.services.google_oauth import ( build_authenticated_flow_payload, build_google_authorization_url, build_google_callback_error_redirect_url, build_google_callback_redirect_url, build_pending_google_flow_payload, complete_google_signup, consume_google_state, create_google_flow, exchange_code_for_google_profile, find_social_account_for_profile, get_google_flow, send_google_claim_otp, sync_user_from_google_profile, verify_google_claim, ) from core.paginations.limit_offset import CustomLimitOffsetPagination 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,) throttle_classes = [OTPSendBurstThrottle, OTPSendSustainedThrottle] @extend_schema(request=SendOTPSerializer, responses=None) def post(self, request): serializer = SendOTPSerializer(data=request.data) serializer.is_valid(raise_exception=True) payload = generate_and_send_otp( mobile=serializer.validated_data["mobile"], mode=serializer.validated_data["mode"] ) return Response(payload, status=status.HTTP_200_OK) class LoginView(APIView): permission_classes = (AllowAny,) throttle_classes = [PasswordLoginThrottle] @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,) throttle_classes = [OTPLoginThrottle] @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 GoogleOAuthStartView(APIView): permission_classes = (AllowAny,) @extend_schema(responses=None) def get(self, request): return HttpResponseRedirect(build_google_authorization_url()) class GoogleOAuthCallbackView(APIView): permission_classes = (AllowAny,) @extend_schema(responses=None) def get(self, request): if request.query_params.get("error"): return HttpResponseRedirect( build_google_callback_error_redirect_url( code=request.query_params.get("error") or "google_sign_in_cancelled", detail=( request.query_params.get("error_description") or "Google sign-in was cancelled." ), ) ) try: consume_google_state(request.query_params.get("state")) profile = exchange_code_for_google_profile(request.query_params.get("code")) social_account = find_social_account_for_profile(profile) if social_account: sync_user_from_google_profile(social_account.user, profile) flow_payload = build_authenticated_flow_payload(social_account.user) else: flow_payload = build_pending_google_flow_payload(profile) flow = create_google_flow(flow_payload) return HttpResponseRedirect(build_google_callback_redirect_url(flow)) except serializers.ValidationError as exc: detail = exc.detail if isinstance(detail, dict): message = detail.get("detail", "Google sign-in could not be completed.") else: message = detail if isinstance(message, list): message = message[0] if message else "Google sign-in could not be completed." return HttpResponseRedirect( build_google_callback_error_redirect_url( code="google_callback_failed", detail=str(message), ) ) class GoogleOAuthFlowView(APIView): permission_classes = (AllowAny,) @extend_schema(responses=None) def get(self, request): serializer = GoogleOAuthFlowSerializer(data=request.query_params) serializer.is_valid(raise_exception=True) return Response(get_google_flow(serializer.validated_data["flow"]), status=status.HTTP_200_OK) class GoogleOAuthCompleteView(APIView): permission_classes = (AllowAny,) @extend_schema(request=GoogleOAuthCompleteSerializer, responses=None) def post(self, request): serializer = GoogleOAuthCompleteSerializer(data=request.data) serializer.is_valid(raise_exception=True) payload = complete_google_signup( flow=serializer.validated_data["flow"], mobile=serializer.validated_data["mobile"], ) return Response(payload, status=status.HTTP_200_OK) class GoogleOAuthClaimSendOtpView(APIView): permission_classes = (AllowAny,) throttle_classes = [GoogleClaimSendBurstThrottle, GoogleClaimSendSustainedThrottle] @extend_schema(request=GoogleOAuthFlowSerializer, responses=None) def post(self, request): serializer = GoogleOAuthFlowSerializer(data=request.data) serializer.is_valid(raise_exception=True) payload = send_google_claim_otp(serializer.validated_data["flow"]) return Response(payload, status=status.HTTP_200_OK) class GoogleOAuthClaimVerifyView(APIView): permission_classes = (AllowAny,) throttle_classes = [GoogleClaimVerifyThrottle] @extend_schema(request=GoogleOAuthClaimVerifySerializer, responses=None) def post(self, request): serializer = GoogleOAuthClaimVerifySerializer(data=request.data) serializer.is_valid(raise_exception=True) payload = verify_google_claim( flow=serializer.validated_data["flow"], code=serializer.validated_data["code"], ) return Response(payload, 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, context={"request": request}) 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) @extend_schema( responses=UserProfilePictureSerializer, operation_id="users_profile_picture_self_delete", ) def delete(self, request): request.user.profile_picture.delete(save=False) request.user.profile_picture = None request.user.save(update_fields=["profile_picture", "updated_at"]) serializer = UserProfilePictureSerializer( instance=request.user, context={"request": request}, ) 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) class UserProfileViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, GenericViewSet): serializer_class = UserProfileSerializer permission_classes = [IsAuthenticated] def get_object(self): return self.request.user class UserSearchAPIView(APIView): permission_classes = [IsAuthenticated] def get(self, request): mobile = request.query_params.get('mobile') if not mobile: return Response( {"detail": "Mobile parameter is required."}, status=status.HTTP_400_BAD_REQUEST ) user = User.objects.filter(mobile=mobile).first() if not user: return Response( {"detail": "User not found."}, status=status.HTTP_404_NOT_FOUND ) serializer = UserSearchSerializer(user, context={"request": request}) return Response(serializer.data, status=status.HTTP_200_OK)