Initial commit - clean project structure

This commit is contained in:
2026-08-18 16:48:15 +03:30
commit 331148037e
147 changed files with 5543 additions and 0 deletions

0
accounts/__init__.py Normal file
View File

27
accounts/admin.py Normal file
View 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
View 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

View 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

View 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')),
],
),
]

View File

67
accounts/models.py Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

17
accounts/urls.py Normal file
View 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
View 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)