Ilo/apps/accounts/api/views.py
2026-05-02 20:01:30 +03:30

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)