235 lines
8.3 KiB
Python
235 lines
8.3 KiB
Python
from django.contrib.auth import get_user_model
|
|
from django.db import transaction
|
|
from drf_spectacular.utils import OpenApiExample, OpenApiResponse, extend_schema, inline_serializer
|
|
from rest_framework import serializers as drf_serializers
|
|
from rest_framework import status
|
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
|
from rest_framework.response import Response
|
|
from rest_framework.views import APIView
|
|
from rest_framework_simplejwt.exceptions import TokenError
|
|
from rest_framework_simplejwt.tokens import RefreshToken
|
|
|
|
from ..models import Role, TeacherProfile
|
|
from ..services.otp import (
|
|
OTPCooldownError,
|
|
OTPVerificationError,
|
|
send_otp,
|
|
verify_otp,
|
|
)
|
|
from ..services.sms import SMSError
|
|
from .serializers import (
|
|
OTPSendSerializer,
|
|
OTPVerifySerializer,
|
|
UserSerializer,
|
|
issue_tokens_for,
|
|
)
|
|
from .throttling import OTPSendByIPThrottle, OTPSendByPhoneThrottle
|
|
|
|
User = get_user_model()
|
|
|
|
|
|
_TOKEN_PAIR = inline_serializer(
|
|
name="TokenPair",
|
|
fields={
|
|
"access": drf_serializers.CharField(),
|
|
"refresh": drf_serializers.CharField(),
|
|
},
|
|
)
|
|
|
|
|
|
class OTPSendView(APIView):
|
|
permission_classes = [AllowAny]
|
|
throttle_classes = [OTPSendByPhoneThrottle, OTPSendByIPThrottle]
|
|
|
|
@extend_schema(
|
|
tags=["auth"],
|
|
summary="Send OTP",
|
|
description=(
|
|
"Generate a 4-digit OTP and dispatch it to the given Iranian phone number "
|
|
"via the configured SMS backend. Phone is normalized to `09xxxxxxxxx`. "
|
|
"Per-phone cooldown of 60 seconds; per-phone hourly cap of 5; per-IP cap of 30/min."
|
|
),
|
|
request=OTPSendSerializer,
|
|
responses={
|
|
200: OpenApiResponse(
|
|
response=inline_serializer(
|
|
name="OTPSendResponse",
|
|
fields={
|
|
"detail": drf_serializers.CharField(),
|
|
"phone_number": drf_serializers.CharField(),
|
|
},
|
|
),
|
|
description="OTP issued and SMS dispatched.",
|
|
),
|
|
400: OpenApiResponse(description="Phone number is missing or invalid."),
|
|
429: OpenApiResponse(
|
|
response=inline_serializer(
|
|
name="OTPCooldownResponse",
|
|
fields={
|
|
"detail": drf_serializers.CharField(),
|
|
"retry_after": drf_serializers.IntegerField(),
|
|
},
|
|
),
|
|
description="Cooldown active or hourly cap reached.",
|
|
),
|
|
502: OpenApiResponse(description="SMS provider is unreachable or returned an error."),
|
|
},
|
|
examples=[
|
|
OpenApiExample(
|
|
"Iran phone, canonical",
|
|
value={"phone_number": "09121234567"},
|
|
request_only=True,
|
|
),
|
|
OpenApiExample(
|
|
"Iran phone, E.164",
|
|
value={"phone_number": "+989121234567"},
|
|
request_only=True,
|
|
),
|
|
],
|
|
)
|
|
def post(self, request):
|
|
ser = OTPSendSerializer(data=request.data)
|
|
ser.is_valid(raise_exception=True)
|
|
phone = ser.validated_data["phone_number"]
|
|
|
|
try:
|
|
send_otp(phone)
|
|
except OTPCooldownError as e:
|
|
return Response(
|
|
{"detail": "Too soon", "retry_after": e.retry_after},
|
|
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
)
|
|
except SMSError as e:
|
|
return Response(
|
|
{"detail": "Could not send SMS", "error": str(e)},
|
|
status=status.HTTP_502_BAD_GATEWAY,
|
|
)
|
|
|
|
return Response({"detail": "Code sent", "phone_number": phone})
|
|
|
|
|
|
class OTPVerifyView(APIView):
|
|
permission_classes = [AllowAny]
|
|
|
|
@extend_schema(
|
|
tags=["auth"],
|
|
summary="Verify OTP and login or signup",
|
|
description=(
|
|
"Consumes the latest active OTP for the phone.\n\n"
|
|
"- **Existing user** → login. Body needs only `phone_number` and `code`.\n"
|
|
"- **New user** → signup. Body must also include `full_name` and `role` (`student` or `teacher`). "
|
|
"If `role=teacher`, a `TeacherProfile` is auto-created with `is_approved=False` "
|
|
"(an admin must approve before the teacher can publish courses).\n\n"
|
|
"Returns access + refresh JWTs on success."
|
|
),
|
|
request=OTPVerifySerializer,
|
|
responses={
|
|
200: OpenApiResponse(
|
|
response=inline_serializer(
|
|
name="OTPVerifyResponse",
|
|
fields={
|
|
"created": drf_serializers.BooleanField(),
|
|
"user": UserSerializer(),
|
|
"tokens": _TOKEN_PAIR,
|
|
},
|
|
),
|
|
description="Login or signup succeeded; tokens issued.",
|
|
),
|
|
400: OpenApiResponse(
|
|
description=(
|
|
"Possible reasons: invalid code, code expired, no active code for phone, "
|
|
"too many wrong attempts, or signup fields missing for new account."
|
|
)
|
|
),
|
|
},
|
|
examples=[
|
|
OpenApiExample(
|
|
"Existing user — login",
|
|
value={"phone_number": "09121234567", "code": "1234"},
|
|
request_only=True,
|
|
),
|
|
OpenApiExample(
|
|
"New user — signup as teacher",
|
|
value={
|
|
"phone_number": "09121234567",
|
|
"code": "1234",
|
|
"full_name": "Ali Reza",
|
|
"role": "teacher",
|
|
},
|
|
request_only=True,
|
|
),
|
|
],
|
|
)
|
|
def post(self, request):
|
|
ser = OTPVerifySerializer(data=request.data)
|
|
ser.is_valid(raise_exception=True)
|
|
phone = ser.validated_data["phone_number"]
|
|
code = ser.validated_data["code"]
|
|
|
|
try:
|
|
verify_otp(phone, code)
|
|
except OTPVerificationError as e:
|
|
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
user = User.objects.filter(phone_number=phone).first()
|
|
created = False
|
|
if user is None:
|
|
with transaction.atomic():
|
|
user = User.objects.create_user(
|
|
phone_number=phone,
|
|
full_name=ser.validated_data["full_name"],
|
|
role=ser.validated_data["role"],
|
|
)
|
|
if user.role == Role.TEACHER:
|
|
TeacherProfile.objects.create(user=user)
|
|
created = True
|
|
|
|
tokens = issue_tokens_for(user)
|
|
return Response(
|
|
{
|
|
"created": created,
|
|
"user": UserSerializer(user).data,
|
|
"tokens": tokens,
|
|
}
|
|
)
|
|
|
|
|
|
class MeView(APIView):
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
@extend_schema(
|
|
tags=["auth"],
|
|
summary="Current user",
|
|
description="Returns the authenticated user's profile (id, phone, full name, role, email, language).",
|
|
responses={200: UserSerializer, 401: OpenApiResponse(description="Missing or invalid token.")},
|
|
)
|
|
def get(self, request):
|
|
return Response(UserSerializer(request.user).data)
|
|
|
|
|
|
class LogoutView(APIView):
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
@extend_schema(
|
|
tags=["auth"],
|
|
summary="Logout (blacklist refresh token)",
|
|
description="Blacklists the supplied refresh token so it cannot be rotated again. The access token remains valid until natural expiry.",
|
|
request=inline_serializer(
|
|
name="LogoutRequest",
|
|
fields={"refresh": drf_serializers.CharField()},
|
|
),
|
|
responses={
|
|
204: OpenApiResponse(description="Refresh token blacklisted."),
|
|
400: OpenApiResponse(description="Missing or invalid refresh token."),
|
|
},
|
|
)
|
|
def post(self, request):
|
|
refresh = request.data.get("refresh")
|
|
if not refresh:
|
|
return Response({"detail": "refresh required"}, status=status.HTTP_400_BAD_REQUEST)
|
|
try:
|
|
RefreshToken(refresh).blacklist()
|
|
except TokenError as e:
|
|
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
|
return Response(status=status.HTTP_204_NO_CONTENT)
|