Initial commit - clean project structure
This commit is contained in:
0
accounts/__init__.py
Normal file
0
accounts/__init__.py
Normal file
27
accounts/admin.py
Normal file
27
accounts/admin.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import OTPCode, Profile, Session, User
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'name', 'phone', 'device_id', 'is_guest', 'grade_id', 'created_at')
|
||||
search_fields = ('name', 'phone', 'device_id')
|
||||
list_filter = ('is_guest',)
|
||||
|
||||
|
||||
@admin.register(Profile)
|
||||
class ProfileAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'user', 'birth_year', 'parent_phone')
|
||||
|
||||
|
||||
@admin.register(Session)
|
||||
class SessionAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'user', 'device_id', 'expires_at', 'revoked')
|
||||
list_filter = ('revoked',)
|
||||
|
||||
|
||||
@admin.register(OTPCode)
|
||||
class OTPCodeAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'phone', 'code', 'expires_at', 'is_used')
|
||||
list_filter = ('is_used',)
|
||||
10
accounts/apps.py
Normal file
10
accounts/apps.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'accounts'
|
||||
|
||||
def ready(self):
|
||||
# ثبت OpenApiAuthenticationExtension برای دکمهی Authorize توی Swagger UI
|
||||
from . import schema # noqa: F401
|
||||
60
accounts/authentication.py
Normal file
60
accounts/authentication.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from django.conf import settings
|
||||
from django.core import signing
|
||||
from rest_framework.authentication import BaseAuthentication
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
from .models import User
|
||||
|
||||
ACCESS_TOKEN_SALT = 'tadrisino-access-token'
|
||||
|
||||
|
||||
def issue_access_token(user_id: int) -> str:
|
||||
"""
|
||||
ساخت access token امضاشده (stateless) — دیتابیس ذخیره نمیشه، فقط
|
||||
امضا و expiry داره. با django.core.signing (بدون نیاز به کتابخونهی JWT).
|
||||
"""
|
||||
signer = signing.TimestampSigner(salt=ACCESS_TOKEN_SALT)
|
||||
return signer.sign(str(user_id))
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> int:
|
||||
signer = signing.TimestampSigner(salt=ACCESS_TOKEN_SALT)
|
||||
try:
|
||||
value = signer.unsign(token, max_age=settings.ACCESS_TOKEN_TTL_SECONDS)
|
||||
except signing.SignatureExpired:
|
||||
raise AuthenticationFailed('توکن منقضی شده است.')
|
||||
except signing.BadSignature:
|
||||
raise AuthenticationFailed('توکن نامعتبر است.')
|
||||
return int(value)
|
||||
|
||||
|
||||
class TadrisinoTokenAuthentication(BaseAuthentication):
|
||||
"""
|
||||
هدر مورد انتظار: Authorization: Bearer <access_token>
|
||||
اگه هدر نباشه، درخواست بهصورت anonymous ادامه پیدا میکنه (None, None)
|
||||
و View خودش تصمیم میگیره که آیا auth اجباریه (IsAuthenticated) یا نه.
|
||||
"""
|
||||
|
||||
keyword = 'Bearer'
|
||||
|
||||
def authenticate(self, request):
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
if not auth_header:
|
||||
return None
|
||||
|
||||
parts = auth_header.split()
|
||||
if len(parts) != 2 or parts[0] != self.keyword:
|
||||
return None
|
||||
|
||||
token = parts[1]
|
||||
user_id = decode_access_token(token)
|
||||
|
||||
try:
|
||||
user = User.objects.get(pk=user_id)
|
||||
except User.DoesNotExist:
|
||||
raise AuthenticationFailed('کاربر پیدا نشد.')
|
||||
|
||||
return (user, token)
|
||||
|
||||
def authenticate_header(self, request):
|
||||
return self.keyword
|
||||
62
accounts/migrations/0001_initial.py
Normal file
62
accounts/migrations/0001_initial.py
Normal file
@@ -0,0 +1,62 @@
|
||||
# Generated by Django 5.2.17 on 2026-08-10 11:37
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='OTPCode',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('phone', models.CharField(db_index=True, max_length=15)),
|
||||
('code', models.CharField(max_length=6)),
|
||||
('expires_at', models.DateTimeField()),
|
||||
('is_used', models.BooleanField(default=False)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('phone', models.CharField(blank=True, db_index=True, max_length=15, null=True, unique=True)),
|
||||
('device_id', models.CharField(blank=True, db_index=True, max_length=64, null=True)),
|
||||
('name', models.CharField(blank=True, max_length=100)),
|
||||
('avatar', models.CharField(blank=True, max_length=100)),
|
||||
('is_guest', models.BooleanField(default=True)),
|
||||
('grade_id', models.IntegerField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Session',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('refresh_token', models.CharField(max_length=200, unique=True)),
|
||||
('device_id', models.CharField(blank=True, max_length=64)),
|
||||
('expires_at', models.DateTimeField()),
|
||||
('revoked', models.BooleanField(default=False)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='accounts.user')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Profile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('birth_year', models.PositiveSmallIntegerField(blank=True, null=True)),
|
||||
('parent_phone', models.CharField(blank=True, max_length=15)),
|
||||
('bio', models.CharField(blank=True, max_length=200)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to='accounts.user')),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
accounts/migrations/__init__.py
Normal file
0
accounts/migrations/__init__.py
Normal file
67
accounts/models.py
Normal file
67
accounts/models.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class User(models.Model):
|
||||
"""
|
||||
کاربر اصلی سیستم (مهمان یا ثبتنامشده با شماره).
|
||||
توجه: این مدل، AUTH_USER_MODEL جنگو نیست؛ یک مدل بیزنسی مستقله که
|
||||
با TadrisinoTokenAuthentication سفارشی احراز هویت میشه.
|
||||
|
||||
grade_id فقط یک ارجاع سبک (بدون FK واقعی) به content.Grade هست؛
|
||||
این اپ عمداً هیچوقت FK واقعی به مدل اپ دیگه نمیزنه.
|
||||
"""
|
||||
phone = models.CharField(max_length=15, unique=True, null=True, blank=True, db_index=True)
|
||||
device_id = models.CharField(max_length=64, null=True, blank=True, db_index=True)
|
||||
name = models.CharField(max_length=100, blank=True)
|
||||
avatar = models.CharField(max_length=100, blank=True)
|
||||
is_guest = models.BooleanField(default=True)
|
||||
grade_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Grade
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return self.name or self.phone or f"guest-{self.pk}"
|
||||
|
||||
@property
|
||||
def is_authenticated(self):
|
||||
# این مدل AUTH_USER_MODEL نیست؛ این property فقط برای سازگاری با
|
||||
# rest_framework.permissions.IsAuthenticated لازمه.
|
||||
return True
|
||||
|
||||
|
||||
class Profile(models.Model):
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
|
||||
birth_year = models.PositiveSmallIntegerField(null=True, blank=True)
|
||||
parent_phone = models.CharField(max_length=15, blank=True)
|
||||
bio = models.CharField(max_length=200, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"profile-{self.user_id}"
|
||||
|
||||
|
||||
class Session(models.Model):
|
||||
"""
|
||||
نگهداری refresh_token هر ورود. توکن دسترسی (access token) در دیتابیس
|
||||
ذخیره نمیشه؛ بهصورت stateless امضا (signed) میشه — دقیقاً طبق فیلدهای
|
||||
همین مدل که فقط refresh_token داره، نه access token.
|
||||
"""
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sessions')
|
||||
refresh_token = models.CharField(max_length=200, unique=True)
|
||||
device_id = models.CharField(max_length=64, blank=True)
|
||||
expires_at = models.DateTimeField()
|
||||
revoked = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
return f"session-{self.pk}-user{self.user_id}"
|
||||
|
||||
|
||||
class OTPCode(models.Model):
|
||||
phone = models.CharField(max_length=15, db_index=True)
|
||||
code = models.CharField(max_length=6)
|
||||
expires_at = models.DateTimeField()
|
||||
is_used = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
return f"otp-{self.phone}"
|
||||
22
accounts/schema.py
Normal file
22
accounts/schema.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from drf_spectacular.extensions import OpenApiAuthenticationExtension
|
||||
|
||||
|
||||
class TadrisinoTokenAuthenticationScheme(OpenApiAuthenticationExtension):
|
||||
"""
|
||||
این کلاس به drf-spectacular میگه که TadrisinoTokenAuthentication یک
|
||||
Bearer token هست، تا دکمهی "Authorize" توی Swagger UI درست نمایش داده بشه.
|
||||
"""
|
||||
target_class = 'accounts.authentication.TadrisinoTokenAuthentication'
|
||||
name = 'BearerAuth'
|
||||
|
||||
def get_security_definition(self, auto_schema):
|
||||
return {
|
||||
'type': 'http',
|
||||
'scheme': 'bearer',
|
||||
'bearerFormat': 'access_token',
|
||||
'description': (
|
||||
'توکن دسترسی که از /api/auth/session یا /api/auth/login گرفته میشه. '
|
||||
'در فیلد Value فقط خودِ توکن رو بذارید (بدون کلمهی Bearer)؛ Swagger '
|
||||
'خودش اضافه میکنه.'
|
||||
),
|
||||
}
|
||||
68
accounts/serializers.py
Normal file
68
accounts/serializers.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
# ---- Request serializers ----
|
||||
|
||||
class GuestSessionRequestSerializer(serializers.Serializer):
|
||||
type = serializers.ChoiceField(choices=['guest'])
|
||||
deviceId = serializers.CharField(max_length=64)
|
||||
|
||||
|
||||
class LoginRequestSerializer(serializers.Serializer):
|
||||
phone = serializers.CharField(max_length=15)
|
||||
otp = serializers.CharField(max_length=6)
|
||||
|
||||
|
||||
class RefreshRequestSerializer(serializers.Serializer):
|
||||
refreshToken = serializers.CharField()
|
||||
|
||||
|
||||
class SetGradeRequestSerializer(serializers.Serializer):
|
||||
gradeId = serializers.IntegerField()
|
||||
|
||||
|
||||
class RequestOTPRequestSerializer(serializers.Serializer):
|
||||
"""اپ کمکی برای گرفتن OTP (در سند اصلی نبود، برای تستپذیری Swagger اضافه شد)."""
|
||||
phone = serializers.CharField(max_length=15)
|
||||
|
||||
|
||||
# ---- Response serializers ----
|
||||
|
||||
class GuestSessionResponseSerializer(serializers.Serializer):
|
||||
userId = serializers.CharField()
|
||||
token = serializers.CharField()
|
||||
isNewUser = serializers.BooleanField()
|
||||
isGuest = serializers.BooleanField()
|
||||
|
||||
|
||||
class LoginResponseSerializer(serializers.Serializer):
|
||||
userId = serializers.CharField()
|
||||
token = serializers.CharField()
|
||||
refreshToken = serializers.CharField()
|
||||
|
||||
|
||||
class RefreshResponseSerializer(serializers.Serializer):
|
||||
token = serializers.CharField()
|
||||
expiresIn = serializers.IntegerField()
|
||||
|
||||
|
||||
class GradeSerializer(serializers.Serializer):
|
||||
id = serializers.IntegerField()
|
||||
title = serializers.CharField()
|
||||
icon = serializers.CharField()
|
||||
color = serializers.CharField()
|
||||
|
||||
|
||||
class GradesListResponseSerializer(serializers.Serializer):
|
||||
grades = GradeSerializer(many=True)
|
||||
|
||||
|
||||
class SetGradeResponseSerializer(serializers.Serializer):
|
||||
success = serializers.BooleanField()
|
||||
gradeId = serializers.IntegerField()
|
||||
|
||||
|
||||
class RequestOTPResponseSerializer(serializers.Serializer):
|
||||
success = serializers.BooleanField()
|
||||
devOtp = serializers.CharField()
|
||||
note = serializers.CharField()
|
||||
11
accounts/signals.py
Normal file
11
accounts/signals.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
سیگنال ثبتنامِ واقعی کاربر (نه guest session). سایر اپها (مثلاً discount
|
||||
برای اختصاص خودکار آفر) بهجای import کردن مستقیم از accounts، فقط این
|
||||
سیگنال رو در apps.py خودشون connect میکنن؛ این یعنی accounts هیچوقت لازم
|
||||
نیست بدونه discount/offer اصلاً وجود داره — دقیقاً همون درخواست سینیور برای
|
||||
signal-based بودن منطق اعمال آفر.
|
||||
"""
|
||||
from django.dispatch import Signal
|
||||
|
||||
# kwargs: user_id (int)
|
||||
user_registered = Signal()
|
||||
3
accounts/tests.py
Normal file
3
accounts/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
17
accounts/urls.py
Normal file
17
accounts/urls.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import (
|
||||
GuestSessionView,
|
||||
LoginView,
|
||||
RefreshTokenView,
|
||||
RequestOTPView,
|
||||
SetUserGradeView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path('auth/session', GuestSessionView.as_view(), name='auth-session'),
|
||||
path('auth/otp/request', RequestOTPView.as_view(), name='auth-otp-request'),
|
||||
path('auth/login', LoginView.as_view(), name='auth-login'),
|
||||
path('auth/refresh', RefreshTokenView.as_view(), name='auth-refresh'),
|
||||
path('user/grade', SetUserGradeView.as_view(), name='user-grade'),
|
||||
]
|
||||
194
accounts/views.py
Normal file
194
accounts/views.py
Normal file
@@ -0,0 +1,194 @@
|
||||
import random
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from .authentication import issue_access_token
|
||||
from .models import OTPCode, Session, User
|
||||
from .serializers import (
|
||||
GuestSessionRequestSerializer,
|
||||
GuestSessionResponseSerializer,
|
||||
LoginRequestSerializer,
|
||||
LoginResponseSerializer,
|
||||
RefreshRequestSerializer,
|
||||
RefreshResponseSerializer,
|
||||
RequestOTPRequestSerializer,
|
||||
RequestOTPResponseSerializer,
|
||||
SetGradeRequestSerializer,
|
||||
SetGradeResponseSerializer,
|
||||
)
|
||||
|
||||
|
||||
class GuestSessionView(APIView):
|
||||
"""POST /api/auth/session — ایجاد سشن مهمان"""
|
||||
|
||||
@extend_schema(
|
||||
request=GuestSessionRequestSerializer,
|
||||
responses=GuestSessionResponseSerializer,
|
||||
tags=['Identity & Profile (accounts)'],
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = GuestSessionRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
device_id = serializer.validated_data['deviceId']
|
||||
|
||||
user, is_new_user = User.objects.get_or_create(
|
||||
device_id=device_id,
|
||||
is_guest=True,
|
||||
defaults={'name': ''},
|
||||
)
|
||||
|
||||
token = issue_access_token(user.id)
|
||||
|
||||
data = {
|
||||
'userId': str(user.id),
|
||||
'token': token,
|
||||
'isNewUser': is_new_user,
|
||||
'isGuest': True,
|
||||
}
|
||||
return Response(GuestSessionResponseSerializer(data).data)
|
||||
|
||||
|
||||
class RequestOTPView(APIView):
|
||||
"""
|
||||
POST /api/auth/otp/request — درخواست کد OTP (کمکی، برای تست از طریق Swagger)
|
||||
در محیط توسعه، کد ساختهشده در پاسخ برگردونده میشه تا نیازی به SMS واقعی نباشه.
|
||||
"""
|
||||
|
||||
@extend_schema(
|
||||
request=RequestOTPRequestSerializer,
|
||||
responses=RequestOTPResponseSerializer,
|
||||
tags=['Identity & Profile (accounts)'],
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = RequestOTPRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
phone = serializer.validated_data['phone']
|
||||
|
||||
code = f'{random.randint(0, 9999):04d}'
|
||||
OTPCode.objects.create(
|
||||
phone=phone,
|
||||
code=code,
|
||||
expires_at=timezone.now() + timezone.timedelta(minutes=5),
|
||||
)
|
||||
data = {
|
||||
'success': True,
|
||||
'devOtp': code,
|
||||
'note': f'در پروداکشن این کد پیامک میشه؛ برای تست همیشه {settings.DEV_MASTER_OTP} هم قبول میشه.',
|
||||
}
|
||||
return Response(RequestOTPResponseSerializer(data).data)
|
||||
|
||||
|
||||
class LoginView(APIView):
|
||||
"""POST /api/auth/login — ورود با OTP"""
|
||||
|
||||
@extend_schema(
|
||||
request=LoginRequestSerializer,
|
||||
responses=LoginResponseSerializer,
|
||||
tags=['Identity & Profile (accounts)'],
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = LoginRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
phone = serializer.validated_data['phone']
|
||||
otp = serializer.validated_data['otp']
|
||||
|
||||
valid = False
|
||||
if otp == settings.DEV_MASTER_OTP:
|
||||
valid = True
|
||||
else:
|
||||
otp_qs = OTPCode.objects.filter(
|
||||
phone=phone, code=otp, is_used=False, expires_at__gte=timezone.now()
|
||||
).order_by('-id')
|
||||
otp_obj = otp_qs.first()
|
||||
if otp_obj:
|
||||
otp_obj.is_used = True
|
||||
otp_obj.save(update_fields=['is_used'])
|
||||
valid = True
|
||||
|
||||
if not valid:
|
||||
return Response({'detail': 'کد OTP نامعتبر یا منقضیشده است.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
user, created = User.objects.get_or_create(
|
||||
phone=phone,
|
||||
defaults={'is_guest': False, 'name': ''},
|
||||
)
|
||||
if user.is_guest:
|
||||
user.is_guest = False
|
||||
user.save(update_fields=['is_guest'])
|
||||
|
||||
if created:
|
||||
# ثبتنام واقعی (نه guest session): آفرهای auto_assign_on_signup
|
||||
# (مثل یک هفته رایگان) از طریق discount/receivers.py روی همین
|
||||
# سیگنال اعمال میشن؛ این ویو از وجود آفر/discount چیزی نمیدونه.
|
||||
from .signals import user_registered
|
||||
user_registered.send(sender=User, user_id=user.id)
|
||||
|
||||
refresh_token = uuid.uuid4().hex
|
||||
Session.objects.create(
|
||||
user=user,
|
||||
refresh_token=refresh_token,
|
||||
device_id=request.data.get('deviceId', ''),
|
||||
expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
|
||||
)
|
||||
access_token = issue_access_token(user.id)
|
||||
|
||||
data = {
|
||||
'userId': str(user.id),
|
||||
'token': access_token,
|
||||
'refreshToken': refresh_token,
|
||||
}
|
||||
return Response(LoginResponseSerializer(data).data)
|
||||
|
||||
|
||||
class RefreshTokenView(APIView):
|
||||
"""POST /api/auth/refresh — تمدید توکن"""
|
||||
|
||||
@extend_schema(
|
||||
request=RefreshRequestSerializer,
|
||||
responses=RefreshResponseSerializer,
|
||||
tags=['Identity & Profile (accounts)'],
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = RefreshRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
refresh_token = serializer.validated_data['refreshToken']
|
||||
|
||||
session = Session.objects.filter(
|
||||
refresh_token=refresh_token, revoked=False, expires_at__gte=timezone.now()
|
||||
).first()
|
||||
if not session:
|
||||
return Response({'detail': 'رفرشتوکن نامعتبر یا منقضیشده است.'}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
access_token = issue_access_token(session.user_id)
|
||||
data = {'token': access_token, 'expiresIn': settings.ACCESS_TOKEN_TTL_SECONDS}
|
||||
return Response(RefreshResponseSerializer(data).data)
|
||||
|
||||
|
||||
class SetUserGradeView(APIView):
|
||||
"""POST /api/user/grade — ثبت پایهی کاربر (نیاز به احراز هویت)"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
request=SetGradeRequestSerializer,
|
||||
responses=SetGradeResponseSerializer,
|
||||
tags=['Identity & Profile (accounts)'],
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = SetGradeRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
grade_id = serializer.validated_data['gradeId']
|
||||
|
||||
user = request.user
|
||||
user.grade_id = grade_id
|
||||
user.save(update_fields=['grade_id'])
|
||||
|
||||
data = {'success': True, 'gradeId': grade_id}
|
||||
return Response(SetGradeResponseSerializer(data).data)
|
||||
Reference in New Issue
Block a user