Initial commit

This commit is contained in:
2026-08-11 16:43:12 +03:30
commit e1d830f6a9
127 changed files with 4454 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
venv/
__pycache__/
*.pyc
db.sqlite3
.DS_Store
.env

45
README.md Normal file
View File

@@ -0,0 +1,45 @@
# Tadrisino API — فاز ۱ (۴ از ۱۱ اپ)
پیاده‌سازی اولیه‌ی ۴ اپ اول (از ۱۱ اپ طراحی‌شده): `accounts`, `content`, `progress`, `ai_tutor`.
بقیه‌ی اپ‌ها (subscription, payment, discount, affiliate, support, notifications, family) در فاز بعدی اضافه می‌شن.
## اجرا
```bash
python3 -m venv venv
source venv/bin/activate # ویندوز: venv\Scripts\activate
pip install -r requirements.txt
python manage.py migrate
python manage.py createsuperuser # اختیاری، برای /admin
python manage.py runserver
```
## تست با Swagger
مرورگر: **http://127.0.0.1:8000/api/docs/**
(Redoc هم هست: `/api/redoc/`، و پنل ادمین: `/admin/`)
## جریان تست پیشنهادی
1. `POST /api/auth/session` با `{"type":"guest","deviceId":"d_test1"}` → یک `token` می‌گیرید.
2. توی Swagger روی دکمه‌ی **Authorize** بزنید و فقط خودِ `token` رو بدون کلمه‌ی Bearer وارد کنید.
3. حالا endpointهای نیازمند auth (مثل `/api/user/grade`, `/api/user/summary`, `/api/ai/ask`) کار می‌کنن.
4. برای تست لاگین با شماره: `POST /api/auth/otp/request` بزنید (کد OTP رو در پاسخ برمی‌گردونه چون SMS واقعی وصل نیست)، یا مستقیم کد **0000** رو با هر شماره‌ای در `/api/auth/login` امتحان کنید (فقط در حالت توسعه).
5. برای تست محتوا: از پنل `/admin/` چند `Grade → Unit → Node → Lesson → Question → Choice` بسازید (حتماً یک Choice رو `is_correct=True` کنید)، بعد `/api/home?userId=1&grade=1`، `/api/lessons/{id}/questions`، `/api/questions/{id}/answer?userId=1` رو تست کنید.
## نکات مهم / اصلاحات نسبت به سند اولیه
- **مدل `Choice`** در سند شما فیلد `is_correct` نداشت (خودتون هم توی سند بهش اشاره کرده بودید که شاید سهواً جا افتاده). بدون این فیلد امکان پیاده‌سازی `POST /api/questions/{id}/answer` نبود، پس اضافه‌ش کردم (کامنت توضیح در `content/models.py`).
- احراز هویت: از یک **Bearer token امضاشده و stateless** (بدون کتابخونه‌ی JWT خارجی، با `django.core.signing`) استفاده شده که دقیقاً با فیلدهای مدل `Session` شما (که فقط `refresh_token` داره، نه access token) سازگاره.
- اپ `accounts.User` عمداً `AUTH_USER_MODEL` جنگو نیست؛ یک مدل بیزنسی مستقله، طبق طراحی خودتون.
- endpoint `POST /api/ai/ask` فعلاً پاسخ **stub** برمی‌گردونه (بدون اتصال به مدل واقعی هوش مصنوعی) — محل اتصال با کامنت مشخص شده (`ai_tutor/views.py` تابع `generate_tutor_answer`).
- endpoint کمکی که در سند نبود ولی برای تست‌پذیری اضافه شد: `POST /api/auth/otp/request` (گرفتن کد OTP بدون SMS واقعی).
## ساختار
```
config/ تنظیمات پروژه (settings, urls اصلی)
accounts/ Identity & Profile
content/ Curriculum
progress/ Progress & Gamification
ai_tutor/ AI Tutor
```
## اپ‌های باقی‌مونده (فاز بعدی)
subscription, payment, discount, affiliate, support, notifications, family

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()

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

187
accounts/views.py Normal file
View File

@@ -0,0 +1,187 @@
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, _ = 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'])
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)

0
affiliate/__init__.py Normal file
View File

21
affiliate/admin.py Normal file
View File

@@ -0,0 +1,21 @@
from django.contrib import admin
from .models import AffiliateAccount, Conversion
class ConversionInline(admin.TabularInline):
model = Conversion
extra = 0
@admin.register(AffiliateAccount)
class AffiliateAccountAdmin(admin.ModelAdmin):
list_display = ('id', 'user_id', 'referral_code', 'commission_rate', 'created_at')
search_fields = ('referral_code',)
inlines = [ConversionInline]
@admin.register(Conversion)
class ConversionAdmin(admin.ModelAdmin):
list_display = ('id', 'affiliate_account', 'referred_user_id', 'status', 'commission_amount', 'created_at')
list_filter = ('status',)

6
affiliate/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AffiliateConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'affiliate'

View File

@@ -0,0 +1,41 @@
# Generated by Django 6.1 on 2026-08-10 15:43
import affiliate.models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='AffiliateAccount',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(db_index=True, unique=True)),
('referral_code', models.CharField(default=affiliate.models.generate_referral_code, max_length=20, unique=True)),
('commission_rate', models.FloatField(default=0.1)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='Conversion',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('referred_user_id', models.IntegerField(db_index=True, unique=True)),
('order_id', models.IntegerField(blank=True, null=True)),
('status', models.CharField(choices=[('pending', 'در انتظار'), ('paid', 'پرداخت\u200cشده')], default='pending', max_length=10)),
('commission_amount', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('affiliate_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='conversions', to='affiliate.affiliateaccount')),
],
options={
'ordering': ['-created_at'],
},
),
]

View File

39
affiliate/models.py Normal file
View File

@@ -0,0 +1,39 @@
import secrets
import string
from django.db import models
def generate_referral_code() -> str:
"""کد ۸ کاراکتری حروف‌بزرگ/عدد، مثل SARA10 در سند نمونه."""
alphabet = string.ascii_uppercase + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(8))
class AffiliateAccount(models.Model):
user_id = models.IntegerField(unique=True, db_index=True) # ارجاع سبک به accounts.User
referral_code = models.CharField(max_length=20, unique=True, default=generate_referral_code)
commission_rate = models.FloatField(default=0.1)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"affiliate-user{self.user_id}-{self.referral_code}"
class Conversion(models.Model):
STATUS_CHOICES = [('pending', 'در انتظار'), ('paid', 'پرداخت‌شده')]
affiliate_account = models.ForeignKey(
AffiliateAccount, on_delete=models.CASCADE, related_name='conversions'
)
referred_user_id = models.IntegerField(unique=True, db_index=True) # ارجاع سبک به accounts.User
order_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به payment.Order
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending')
commission_amount = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f"conversion-{self.pk}-referred{self.referred_user_id}"

28
affiliate/serializers.py Normal file
View File

@@ -0,0 +1,28 @@
from rest_framework import serializers
class AffiliateMeResponseSerializer(serializers.Serializer):
referralCode = serializers.CharField()
commissionRate = serializers.FloatField()
class TrackRequestSerializer(serializers.Serializer):
referralCode = serializers.CharField()
referredUserId = serializers.CharField()
class TrackResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()
conversionId = serializers.CharField()
status = serializers.CharField()
class ConversionItemSerializer(serializers.Serializer):
id = serializers.CharField()
referredUserId = serializers.CharField()
status = serializers.CharField()
commissionAmount = serializers.IntegerField()
class ConversionListResponseSerializer(serializers.Serializer):
conversions = ConversionItemSerializer(many=True)

3
affiliate/tests.py Normal file
View File

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

9
affiliate/urls.py Normal file
View File

@@ -0,0 +1,9 @@
from django.urls import path
from .views import AffiliateMeView, ConversionsListView, TrackConversionView
urlpatterns = [
path('affiliate/me', AffiliateMeView.as_view(), name='affiliate-me'),
path('affiliate/track', TrackConversionView.as_view(), name='affiliate-track'),
path('affiliate/conversions', ConversionsListView.as_view(), name='affiliate-conversions'),
]

92
affiliate/views.py Normal file
View File

@@ -0,0 +1,92 @@
from django.shortcuts import get_object_or_404
from drf_spectacular.utils import extend_schema
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import AffiliateAccount, Conversion
from .serializers import (
AffiliateMeResponseSerializer,
ConversionListResponseSerializer,
TrackRequestSerializer,
TrackResponseSerializer,
)
class AffiliateMeView(APIView):
"""GET /api/affiliate/me — حساب و کد ارجاع کاربر"""
permission_classes = [IsAuthenticated]
@extend_schema(responses=AffiliateMeResponseSerializer, tags=['Affiliate (affiliate)'])
def get(self, request):
account, _ = AffiliateAccount.objects.get_or_create(user_id=request.user.id)
data = {'referralCode': account.referral_code, 'commissionRate': account.commission_rate}
return Response(AffiliateMeResponseSerializer(data).data)
class TrackConversionView(APIView):
"""
POST /api/affiliate/track — ثبت کلیک/ارجاع
طبق سند، این اپ فقط با referralCode به AffiliateAccount وصل می‌شه
(نه توکن کاربر)، چون referredUserId خودش کاربر تازه‌ارجاع‌شده‌ست که
لزوماً صاحب حساب افیلیت نیست؛ پس اینجا permission پیش‌فرض (AllowAny)
نگه داشته شده، دقیقاً مثل ValidateDiscountView در اپ discount.
"""
@extend_schema(
request=TrackRequestSerializer, responses=TrackResponseSerializer, tags=['Affiliate (affiliate)']
)
def post(self, request):
serializer = TrackRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
account = get_object_or_404(AffiliateAccount, referral_code=v['referralCode'])
referred_user_id = int(v['referredUserId'])
# ⚠️ اصلاح امنیتی: قبلاً referredUserId بدون هیچ اعتبارسنجی‌ای
# مستقیم مصرف می‌شد؛ یعنی می‌شد برای یک کاربر ناموجود Conversion
# ساخت یا با فرستادن userId خودِ صاحب حساب affiliate، به خودش
# ارجاع داد (self-referral). حالا هر دو حالت رد می‌شن.
from accounts.models import User as AccountUser
if not AccountUser.objects.filter(pk=referred_user_id).exists():
raise ValidationError({'referredUserId': 'چنین کاربری وجود ندارد.'})
if referred_user_id == account.user_id:
raise ValidationError({'referredUserId': 'امکان ثبت ارجاع برای خودِ صاحب کد وجود ندارد.'})
conversion, _ = Conversion.objects.get_or_create(
referred_user_id=referred_user_id,
defaults={'affiliate_account': account},
)
data = {'success': True, 'conversionId': str(conversion.pk), 'status': conversion.status}
return Response(TrackResponseSerializer(data).data)
class ConversionsListView(APIView):
"""GET /api/affiliate/conversions — لیست تبدیل‌ها و کمیسیون"""
permission_classes = [IsAuthenticated]
@extend_schema(responses=ConversionListResponseSerializer, tags=['Affiliate (affiliate)'])
def get(self, request):
account = AffiliateAccount.objects.filter(user_id=request.user.id).first()
conversions = account.conversions.all() if account else Conversion.objects.none()
items = [
{
'id': str(c.pk),
'referredUserId': str(c.referred_user_id),
'status': c.status,
'commissionAmount': c.commission_amount,
}
for c in conversions
]
data = {'conversions': items}
return Response(ConversionListResponseSerializer(data).data)

0
ai_tutor/__init__.py Normal file
View File

25
ai_tutor/admin.py Normal file
View File

@@ -0,0 +1,25 @@
from django.contrib import admin
from .models import Conversation, Message, MessageFeedback
class MessageInline(admin.TabularInline):
model = Message
extra = 0
@admin.register(Conversation)
class ConversationAdmin(admin.ModelAdmin):
list_display = ('id', 'user_id', 'lesson_id', 'grade_id', 'created_at')
inlines = [MessageInline]
@admin.register(Message)
class MessageAdmin(admin.ModelAdmin):
list_display = ('id', 'conversation', 'role', 'created_at')
list_filter = ('role',)
@admin.register(MessageFeedback)
class MessageFeedbackAdmin(admin.ModelAdmin):
list_display = ('id', 'message', 'is_helpful')

6
ai_tutor/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AiTutorConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'ai_tutor'

View File

@@ -0,0 +1,48 @@
# 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='Conversation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(db_index=True)),
('lesson_id', models.IntegerField(blank=True, null=True)),
('chapter_id', models.IntegerField(blank=True, null=True)),
('grade_id', models.IntegerField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='Message',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.CharField(choices=[('user', 'کاربر'), ('assistant', 'دستیار')], max_length=10)),
('text', models.TextField()),
('question_id', models.IntegerField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='ai_tutor.conversation')),
],
options={
'ordering': ['created_at'],
},
),
migrations.CreateModel(
name='MessageFeedback',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_helpful', models.BooleanField()),
('message', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='feedback', to='ai_tutor.message')),
],
),
]

View File

36
ai_tutor/models.py Normal file
View File

@@ -0,0 +1,36 @@
from django.db import models
class Conversation(models.Model):
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
lesson_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Lesson
chapter_id = models.IntegerField(null=True, blank=True) # شناسه‌ی فصل RAG (خارج از content/)
grade_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Grade
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"conv-{self.pk}-user{self.user_id}"
class Message(models.Model):
ROLE_CHOICES = [('user', 'کاربر'), ('assistant', 'دستیار')]
conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name='messages')
role = models.CharField(max_length=10, choices=ROLE_CHOICES)
text = models.TextField()
question_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Question
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['created_at']
def __str__(self):
return f"msg-{self.pk}-{self.role}"
class MessageFeedback(models.Model):
message = models.OneToOneField(Message, on_delete=models.CASCADE, related_name='feedback')
is_helpful = models.BooleanField()
def __str__(self):
return f"feedback-msg{self.message_id}"

39
ai_tutor/serializers.py Normal file
View File

@@ -0,0 +1,39 @@
from rest_framework import serializers
class AskRequestSerializer(serializers.Serializer):
conversationId = serializers.CharField(required=False, allow_null=True, allow_blank=True)
lessonId = serializers.CharField(required=False, allow_null=True, allow_blank=True)
chapterId = serializers.CharField(required=False, allow_null=True, allow_blank=True)
gradeId = serializers.IntegerField(required=False, allow_null=True)
questionId = serializers.CharField(required=False, allow_null=True, allow_blank=True)
userQuestion = serializers.CharField()
stream = serializers.BooleanField(required=False, default=False)
class AskResponseSerializer(serializers.Serializer):
conversationId = serializers.CharField()
messageId = serializers.CharField()
answer = serializers.CharField()
suggestedFollowUps = serializers.ListField(child=serializers.CharField())
remainingQuota = serializers.IntegerField()
class MessageSerializer(serializers.Serializer):
messageId = serializers.CharField()
role = serializers.CharField()
text = serializers.CharField()
class ConversationMessagesResponseSerializer(serializers.Serializer):
conversationId = serializers.CharField()
lessonId = serializers.CharField(allow_null=True)
messages = MessageSerializer(many=True)
class FeedbackRequestSerializer(serializers.Serializer):
isHelpful = serializers.BooleanField()
class FeedbackResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()

3
ai_tutor/tests.py Normal file
View File

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

9
ai_tutor/urls.py Normal file
View File

@@ -0,0 +1,9 @@
from django.urls import path
from .views import AskAIView, ConversationMessagesView, MessageFeedbackView
urlpatterns = [
path('ai/ask', AskAIView.as_view(), name='ai-ask'),
path('ai/conversations/<int:conversation_id>/messages', ConversationMessagesView.as_view(), name='ai-conversation-messages'),
path('ai/messages/<int:message_id>/feedback', MessageFeedbackView.as_view(), name='ai-message-feedback'),
]

147
ai_tutor/views.py Normal file
View File

@@ -0,0 +1,147 @@
from django.db import transaction
from django.shortcuts import get_object_or_404
from django.utils import timezone
from drf_spectacular.utils import extend_schema
from rest_framework.exceptions import Throttled
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from accounts.models import User as AccountUser
from .models import Conversation, Message, MessageFeedback
from .serializers import (
AskRequestSerializer,
AskResponseSerializer,
ConversationMessagesResponseSerializer,
FeedbackRequestSerializer,
FeedbackResponseSerializer,
)
DAILY_AI_QUOTA = 15
def generate_tutor_answer(user_question: str, grade_id, lesson_id) -> tuple[str, list[str]]:
"""
استاب پاسخ AI Tutor. اینجا محل اتصال به مدل واقعی (مثلاً Claude API از طریق
Anthropic SDK) هست؛ فعلاً برای اینکه بدون نیاز به کلید API هم بشه کل جریان
رو با Swagger تست کرد، یک پاسخ ساده و قطعی برمی‌گردونه.
"""
answer = (
f'این یک پاسخ نمونه (stub) برای سوال «{user_question}» است. '
'برای اتصال به مدل واقعی هوش مصنوعی، این تابع را در ai_tutor/views.py '
'جایگزین کنید (مثلاً با فراخوانی Anthropic API).'
)
follow_ups = ['یک مثال دیگر بزن', 'بیشتر توضیح بده']
return answer, follow_ups
class AskAIView(APIView):
"""POST /api/ai/ask — سوال از AI (استریم اختیاری؛ در این فاز استریم پیاده نشده)"""
permission_classes = [IsAuthenticated]
@extend_schema(request=AskRequestSerializer, responses=AskResponseSerializer, tags=['AI Tutor (ai_tutor)'])
def post(self, request):
serializer = AskRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
user = request.user
# ⚠️ اصلاح: چک و مصرفِ کوتای روزانه قبلاً race condition داشت —
# دو درخواست هم‌زمان از همون کاربر می‌تونستن هر دو شمارشِ «امروز»
# رو قبل از ساختن پیامِ خودشون بخونن و هر دو رد بشن، در نتیجه از
# سقف ۱۵ سوال روزانه عبور کنن. حالا با قفل‌کردن ردیف کاربر
# (select_for_update) در یک تراکنش اتمیک، چک و مصرفِ کوتا برای هر
# کاربر serialize می‌شه؛ درخواست‌های هم‌زمانِ همون کاربر منتظر آزاد
# شدن قفل می‌مونن و دیگه نمی‌تونن هم‌زمان از کنار چک رد بشن.
with transaction.atomic():
AccountUser.objects.select_for_update().get(pk=user.id)
today = timezone.localdate()
used_today = Message.objects.filter(
conversation__user_id=user.id, role='user', created_at__date=today
).count()
if used_today >= DAILY_AI_QUOTA:
raise Throttled(detail='سقف روزانه‌ی سوال از هوش مصنوعی به پایان رسیده است.')
conversation_id = v.get('conversationId')
if conversation_id:
conversation = get_object_or_404(Conversation, pk=conversation_id, user_id=user.id)
else:
conversation = Conversation.objects.create(
user_id=user.id,
lesson_id=v.get('lessonId') or None,
chapter_id=v.get('chapterId') or None,
grade_id=v.get('gradeId'),
)
Message.objects.create(
conversation=conversation,
role='user',
text=v['userQuestion'],
question_id=v.get('questionId') or None,
)
answer_text, follow_ups = generate_tutor_answer(
v['userQuestion'], v.get('gradeId'), v.get('lessonId')
)
assistant_message = Message.objects.create(
conversation=conversation,
role='assistant',
text=answer_text,
question_id=v.get('questionId') or None,
)
used_today += 1 # همین سوالی که الان ثبت شد هم جزو مصرف امروزه
remaining_quota = max(DAILY_AI_QUOTA - used_today, 0)
data = {
'conversationId': str(conversation.id),
'messageId': str(assistant_message.id),
'answer': answer_text,
'suggestedFollowUps': follow_ups,
'remainingQuota': remaining_quota,
}
return Response(AskResponseSerializer(data).data)
class ConversationMessagesView(APIView):
"""GET /api/ai/conversations/{id}/messages — تاریخچه‌ی مکالمه"""
permission_classes = [IsAuthenticated]
@extend_schema(responses=ConversationMessagesResponseSerializer, tags=['AI Tutor (ai_tutor)'])
def get(self, request, conversation_id):
conversation = get_object_or_404(Conversation, pk=conversation_id, user_id=request.user.id)
messages = conversation.messages.all()
data = {
'conversationId': str(conversation.id),
'lessonId': str(conversation.lesson_id) if conversation.lesson_id else None,
'messages': [
{'messageId': str(m.id), 'role': m.role, 'text': m.text} for m in messages
],
}
return Response(ConversationMessagesResponseSerializer(data).data)
class MessageFeedbackView(APIView):
"""POST /api/ai/messages/{id}/feedback — فیدبک 👍/👎"""
permission_classes = [IsAuthenticated]
@extend_schema(
request=FeedbackRequestSerializer,
responses=FeedbackResponseSerializer,
tags=['AI Tutor (ai_tutor)'],
)
def post(self, request, message_id):
message = get_object_or_404(Message, pk=message_id, conversation__user_id=request.user.id)
serializer = FeedbackRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
MessageFeedback.objects.update_or_create(
message=message,
defaults={'is_helpful': serializer.validated_data['isHelpful']},
)
return Response(FeedbackResponseSerializer({'success': True}).data)

0
config/__init__.py Normal file
View File

16
config/asgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
ASGI config for config project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
application = get_asgi_application()

218
config/settings.py Normal file
View File

@@ -0,0 +1,218 @@
"""
Django settings for config project.
Generated by 'django-admin startproject' using Django 5.2.17.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.2/ref/settings/
"""
import os
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-jn!s*25lz^+bjm101tzmlz8jlj70i0qka++*h-(!0s^^vk-0ss'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# 3rd party
'rest_framework',
'drf_spectacular',
# Tadrisino apps (فاز اول - ۴ از ۱۱ اپ)
'accounts',
'content',
'progress',
'ai_tutor',
# Tadrisino apps (فاز دوم - بخش ۱ از ۳: subscription, payment, discount)
'subscription',
'payment',
'discount',
# Tadrisino apps (فاز دوم - بخش ۲: notifications)
'notifications',
# Tadrisino apps (فاز دوم - بخش ۳: affiliate)
'affiliate',
# Tadrisino apps (فاز دوم - بخش ۴: support)
'support',
# Tadrisino apps (فاز دوم - بخش ۵: family)
'family',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'config.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'config.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# Password validation
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/
LANGUAGE_CODE = 'fa-ir'
TIME_ZONE = 'Asia/Tehran'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/
STATIC_URL = 'static/'
# -----------------------------------------------------------------------
# Tadrisino — تنظیمات پروژه
# -----------------------------------------------------------------------
# این پروژه از مدل User جنگو (auth) استفاده نمی‌کنه؛ طبق طراحی سینیورت
# accounts.User یک مدل مستقل (business model) هست، نه AUTH_USER_MODEL.
# احراز هویت با یک TokenAuthentication سفارشی (accounts/authentication.py)
# روی همین مدل accounts.User انجام می‌شه؛ نه سشن/کوکی جنگو.
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'accounts.authentication.TadrisinoTokenAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.AllowAny',
],
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
'rest_framework.renderers.BrowsableAPIRenderer',
],
}
SPECTACULAR_SETTINGS = {
'TITLE': 'Tadrisino API — فاز ۲ / بخش ۱ (+ Subscription, Payment, Discount)',
'DESCRIPTION': (
'پیاده‌سازی ۸ اپ از ۱۱ اپ طراحی‌شده: '
'accounts, content, progress, ai_tutor (فاز ۱) + '
'subscription, payment, discount (فاز ۲ / بخش ۱) + '
'notifications (فاز ۲ / بخش ۲) + '
'affiliate (فاز ۲ / بخش ۳). '
'بقیه‌ی اپ‌ها (support, family) '
'در ادامه‌ی فاز ۲ اضافه می‌شن.'
),
'VERSION': '0.2.0-phase2-part3',
'SERVE_INCLUDE_SCHEMA': False,
'COMPONENT_SPLIT_REQUEST': True,
}
# اعتبار توکن دسترسی (ثانیه) و رفرش‌توکن (روز)
ACCESS_TOKEN_TTL_SECONDS = 3600
REFRESH_TOKEN_TTL_DAYS = 30
# در محیط توسعه/تست، این کد OTP همیشه معتبره تا بدون نیاز به SMS واقعی
# بشه لاگین رو با Swagger تست کرد. در پروداکشن حتماً حذف بشه.
DEV_MASTER_OTP = '0000'
# -----------------------------------------------------------------------
# Payment security
# -----------------------------------------------------------------------
# POST /api/payment/orders طبق سند یک فراخوانی سرور-به-سرور از اپ
# Subscription‌ه (نه از کلاینت). در عمل subscription/views.py مستقیماً
# تابع payment.services.create_order رو import و صدا می‌زنه، پس این
# endpoint اصلاً لازم نیست از بیرون صدا زده بشه؛ اما چون در urls.py
# رجیستر و AllowAny‌ه، هر کلاینتی می‌تونه با یک userId دلخواه سفارش بسازه.
# برای بستن این حفره، درخواست باید هدر X-Internal-Key رو برابر همین مقدار
# بفرسته؛ در پروداکشن حتماً از env خونده بشه و یک مقدار تصادفی/طولانی باشه.
INTERNAL_SERVICE_KEY = os.environ.get('INTERNAL_SERVICE_KEY', 'dev-internal-service-key-change-me')
# POST /api/payment/webhook قراره از درگاه پرداخت بیاد، نه از یک کلاینت
# دلخواه؛ بدون امضا هر کسی می‌تونه با یک orderId معتبر status=paid بفرسته
# و بدون پرداخت واقعی، اشتراک رو فعال کنه. درگاه‌های واقعی (زرین‌پال و غیره)
# امضای HMAC واقعی خودشون رو دارن؛ اینجا چون درگاه واقعی وصل نیست، به‌جای
# HMAC یک shared-secret ساده به اسم X-Webhook-Secret استفاده شده — همون
# سطح حفاظتی که در سند API برای این endpoint خواسته شده. برای اتصال واقعی
# این باید با بررسی امضای HMAC درگاه جایگزین بشه.
PAYMENT_WEBHOOK_SECRET = os.environ.get('PAYMENT_WEBHOOK_SECRET', 'dev-webhook-secret-change-me')
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

45
config/urls.py Normal file
View File

@@ -0,0 +1,45 @@
"""
مسیرهای اصلی پروژه‌ی Tadrisino — فاز ۱ (۴ از ۱۱ اپ)
هر اپ زیر پیشوند /api/ خودش mount شده. مسیرهای هر endpoint دقیقاً طبق
سند «Endpointهای API» طراحی‌شده تا مقایسه با Swagger راحت باشه.
"""
from django.contrib import admin
from django.urls import include, path
from drf_spectacular.views import (
SpectacularAPIView,
SpectacularRedocView,
SpectacularSwaggerView,
)
urlpatterns = [
path('admin/', admin.site.urls),
# OpenAPI schema + Swagger UI + Redoc
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
path('api/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
# اپ‌های فاز ۱
path('api/', include('accounts.urls')),
path('api/', include('content.urls')),
path('api/', include('progress.urls')),
path('api/', include('ai_tutor.urls')),
# اپ‌های فاز ۲ / بخش ۱ (subscription, payment, discount)
path('api/', include('subscription.urls')),
path('api/', include('payment.urls')),
path('api/', include('discount.urls')),
# اپ‌های فاز ۲ / بخش ۲ (notifications)
path('api/', include('notifications.urls')),
# اپ‌های فاز ۲ / بخش ۳ (affiliate)
path('api/', include('affiliate.urls')),
# اپ‌های فاز ۲ / بخش ۴ (support)
path('api/', include('support.urls')),
# اپ‌های فاز ۲ / بخش ۵ (family)
path('api/', include('family.urls')),
]

16
config/wsgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
WSGI config for config project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
application = get_wsgi_application()

0
content/__init__.py Normal file
View File

50
content/admin.py Normal file
View File

@@ -0,0 +1,50 @@
from django.contrib import admin
from .models import Choice, Grade, Lesson, Node, Question, Unit, UserAnswer, UserLessonCompletion
class ChoiceInline(admin.TabularInline):
model = Choice
extra = 1
@admin.register(Grade)
class GradeAdmin(admin.ModelAdmin):
list_display = ('id', 'title', 'icon', 'color')
@admin.register(Unit)
class UnitAdmin(admin.ModelAdmin):
list_display = ('id', 'title', 'grade', 'order')
list_filter = ('grade',)
@admin.register(Node)
class NodeAdmin(admin.ModelAdmin):
list_display = ('id', 'title', 'unit', 'order')
list_filter = ('unit',)
@admin.register(Lesson)
class LessonAdmin(admin.ModelAdmin):
list_display = ('id', 'title', 'node')
list_filter = ('node',)
@admin.register(Question)
class QuestionAdmin(admin.ModelAdmin):
list_display = ('id', 'prompt', 'lesson', 'type')
list_filter = ('type', 'lesson')
inlines = [ChoiceInline]
@admin.register(UserLessonCompletion)
class UserLessonCompletionAdmin(admin.ModelAdmin):
list_display = ('id', 'user_id', 'lesson', 'correct_count', 'stars', 'xp_earned', 'coins_earned')
list_filter = ('lesson',)
@admin.register(UserAnswer)
class UserAnswerAdmin(admin.ModelAdmin):
list_display = ('id', 'user_id', 'question', 'choice', 'correct', 'xp_earned')
list_filter = ('correct',)

6
content/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ContentConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'content'

View File

@@ -0,0 +1,106 @@
# 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='Grade',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=50)),
('icon', models.CharField(max_length=100)),
('color', models.CharField(max_length=10)),
],
),
migrations.CreateModel(
name='Node',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=150)),
('order', models.PositiveIntegerField(default=0)),
],
options={
'ordering': ['order'],
},
),
migrations.CreateModel(
name='Lesson',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=150)),
('intro_text', models.TextField()),
('mascot_animation', models.CharField(default='explain', max_length=50)),
('node', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lessons', to='content.node')),
],
),
migrations.CreateModel(
name='Question',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('type', models.CharField(choices=[('multiple_choice', 'چهارگزینه\u200cای'), ('count_objects', 'شمارش اشیاء')], max_length=30)),
('prompt', models.TextField()),
('asset_group', models.CharField(blank=True, max_length=100)),
('lesson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='content.lesson')),
],
),
migrations.CreateModel(
name='Choice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('value', models.CharField(max_length=100)),
('is_correct', models.BooleanField(default=False)),
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='choices', to='content.question')),
],
),
migrations.CreateModel(
name='Unit',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=100)),
('order', models.PositiveIntegerField(default=0)),
('grade', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='units', to='content.grade')),
],
options={
'ordering': ['order'],
},
),
migrations.AddField(
model_name='node',
name='unit',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='nodes', to='content.unit'),
),
migrations.CreateModel(
name='UserAnswer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(db_index=True)),
('correct', models.BooleanField()),
('xp_earned', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('choice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='content.choice')),
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='content.question')),
],
),
migrations.CreateModel(
name='UserLessonCompletion',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(db_index=True)),
('correct_count', models.PositiveIntegerField()),
('stars', models.PositiveSmallIntegerField()),
('xp_earned', models.PositiveIntegerField(default=0)),
('coins_earned', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('lesson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='completions', to='content.lesson')),
],
),
]

View File

100
content/models.py Normal file
View File

@@ -0,0 +1,100 @@
from django.db import models
class Grade(models.Model):
title = models.CharField(max_length=50)
icon = models.CharField(max_length=100)
color = models.CharField(max_length=10)
def __str__(self):
return self.title
class Unit(models.Model):
grade = models.ForeignKey(Grade, on_delete=models.CASCADE, related_name='units')
title = models.CharField(max_length=100)
order = models.PositiveIntegerField(default=0)
class Meta:
ordering = ['order']
def __str__(self):
return self.title
class Node(models.Model):
unit = models.ForeignKey(Unit, on_delete=models.CASCADE, related_name='nodes')
title = models.CharField(max_length=150)
order = models.PositiveIntegerField(default=0)
class Meta:
ordering = ['order']
def __str__(self):
return self.title
class Lesson(models.Model):
node = models.ForeignKey(Node, on_delete=models.CASCADE, related_name='lessons')
title = models.CharField(max_length=150)
intro_text = models.TextField()
mascot_animation = models.CharField(max_length=50, default='explain')
def __str__(self):
return self.title
class Question(models.Model):
TYPE_CHOICES = [('multiple_choice', 'چهارگزینه‌ای'), ('count_objects', 'شمارش اشیاء')]
lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE, related_name='questions')
type = models.CharField(max_length=30, choices=TYPE_CHOICES)
prompt = models.TextField()
asset_group = models.CharField(max_length=100, blank=True)
def __str__(self):
return self.prompt[:50]
class Choice(models.Model):
"""
⚠️ اصلاح نسبت به سند اولیه: دیاگرام معماری فقط فیلد `value` رو برای
Choice مشخص کرده بود و فیلد تشخیص گزینه‌ی درست (is_correct) رو نداشت
(این نکته توی سند models هم صراحتاً به‌عنوان یک ایراد/نقص علامت‌گذاری
شده بود: "یا این فیلد سهواً جا افتاده یا تشخیص جای دیگه‌ای انجام می‌شه").
چون بدون این فیلد امکان پیاده‌سازی POST /api/questions/{id}/answer
(که باید correct را برگردونه) وجود نداره، این فیلد اینجا اضافه شده.
اگه منطق تشخیص پاسخ درست قراره جای دیگه‌ای (سرویس content-authoring)
انجام بشه، این فیلد رو می‌شه بعداً حذف/جابه‌جا کرد.
"""
question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name='choices')
value = models.CharField(max_length=100)
is_correct = models.BooleanField(default=False) # <-- اصلاح/افزوده شده
def __str__(self):
return self.value
class UserLessonCompletion(models.Model):
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE, related_name='completions')
correct_count = models.PositiveIntegerField()
stars = models.PositiveSmallIntegerField()
xp_earned = models.PositiveIntegerField(default=0)
coins_earned = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"completion-user{self.user_id}-lesson{self.lesson_id}"
class UserAnswer(models.Model):
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name='answers')
choice = models.ForeignKey(Choice, on_delete=models.CASCADE, related_name='answers')
correct = models.BooleanField()
xp_earned = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"answer-user{self.user_id}-q{self.question_id}"

94
content/serializers.py Normal file
View File

@@ -0,0 +1,94 @@
from rest_framework import serializers
from .models import Choice, Grade, Lesson, Question
# ---- Grades ----
class GradeSerializer(serializers.ModelSerializer):
class Meta:
model = Grade
fields = ['id', 'title', 'icon', 'color']
class GradesListResponseSerializer(serializers.Serializer):
grades = GradeSerializer(many=True)
# ---- Home (skill map + stats) ----
class NodeStatusSerializer(serializers.Serializer):
id = serializers.CharField()
title = serializers.CharField()
status = serializers.ChoiceField(choices=['locked', 'unlocked', 'completed'])
class UnitWithNodesSerializer(serializers.Serializer):
id = serializers.CharField()
title = serializers.CharField()
nodes = NodeStatusSerializer(many=True)
class StatsSerializer(serializers.Serializer):
progressPercent = serializers.FloatField()
xp = serializers.IntegerField()
coins = serializers.IntegerField()
class HomeResponseSerializer(serializers.Serializer):
units = UnitWithNodesSerializer(many=True)
stats = StatsSerializer()
# ---- Lesson intro ----
class LessonIntroResponseSerializer(serializers.Serializer):
lessonId = serializers.CharField()
title = serializers.CharField()
introText = serializers.CharField()
mascotAnimation = serializers.CharField()
# ---- Lesson questions ----
class ChoiceOptionSerializer(serializers.Serializer):
id = serializers.CharField()
value = serializers.CharField()
class QuestionSerializer(serializers.Serializer):
id = serializers.CharField()
type = serializers.CharField()
prompt = serializers.CharField()
options = ChoiceOptionSerializer(many=True)
class LessonQuestionsResponseSerializer(serializers.Serializer):
questions = QuestionSerializer(many=True)
# ---- Answer submission ----
class AnswerRequestSerializer(serializers.Serializer):
selectedOptionId = serializers.IntegerField()
class AnswerResponseSerializer(serializers.Serializer):
correct = serializers.BooleanField()
correctOptionId = serializers.CharField()
xpEarned = serializers.IntegerField()
heartsRemaining = serializers.IntegerField()
# ---- Lesson completion ----
class LessonCompleteRequestSerializer(serializers.Serializer):
correctCount = serializers.IntegerField()
totalQuestions = serializers.IntegerField()
class LessonCompleteResponseSerializer(serializers.Serializer):
stars = serializers.IntegerField()
xpEarned = serializers.IntegerField()
coinsEarned = serializers.IntegerField()
stats = StatsSerializer()

3
content/tests.py Normal file
View File

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

19
content/urls.py Normal file
View File

@@ -0,0 +1,19 @@
from django.urls import path
from .views import (
GradesListView,
HomeView,
LessonCompleteView,
LessonIntroView,
LessonQuestionsView,
QuestionAnswerView,
)
urlpatterns = [
path('grades', GradesListView.as_view(), name='grades-list'),
path('home', HomeView.as_view(), name='home'),
path('lessons/<int:lesson_id>/intro', LessonIntroView.as_view(), name='lesson-intro'),
path('lessons/<int:lesson_id>/questions', LessonQuestionsView.as_view(), name='lesson-questions'),
path('lessons/<int:lesson_id>/complete', LessonCompleteView.as_view(), name='lesson-complete'),
path('questions/<int:question_id>/answer', QuestionAnswerView.as_view(), name='question-answer'),
]

247
content/views.py Normal file
View File

@@ -0,0 +1,247 @@
from django.db.models import Sum
from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, 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 progress.models import UserHearts, UserStats
from .models import Choice, Grade, Lesson, Question, UserAnswer, UserLessonCompletion
from .serializers import (
AnswerRequestSerializer,
AnswerResponseSerializer,
GradesListResponseSerializer,
HomeResponseSerializer,
LessonCompleteRequestSerializer,
LessonCompleteResponseSerializer,
LessonIntroResponseSerializer,
LessonQuestionsResponseSerializer,
)
class GradesListView(APIView):
"""GET /api/grades — لیست پایه‌ها"""
@extend_schema(responses=GradesListResponseSerializer, tags=['Curriculum (content)'])
def get(self, request):
grades = Grade.objects.all().order_by('id')
data = {'grades': [
{'id': g.id, 'title': g.title, 'icon': g.icon, 'color': g.color} for g in grades
]}
return Response(GradesListResponseSerializer(data).data)
def _node_status(node, user_id, is_first_unlocked_candidate):
lesson_ids = list(node.lessons.values_list('id', flat=True))
if not lesson_ids:
return 'locked'
completed_count = UserLessonCompletion.objects.filter(
user_id=user_id, lesson_id__in=lesson_ids
).values('lesson_id').distinct().count()
if completed_count >= len(lesson_ids):
return 'completed'
if completed_count > 0 or is_first_unlocked_candidate:
return 'unlocked'
return 'locked'
class HomeView(APIView):
"""GET /api/home?userId&grade — نقشه‌ی مهارت‌ها + آمار (ادغام‌شده)"""
@extend_schema(
parameters=[
OpenApiParameter('userId', OpenApiTypes.INT, required=True),
OpenApiParameter('grade', OpenApiTypes.INT, required=False),
],
responses=HomeResponseSerializer,
tags=['Curriculum (content)'],
)
def get(self, request):
user_id = request.query_params.get('userId')
grade_id = request.query_params.get('grade')
if not user_id:
return Response({'detail': 'پارامتر userId الزامی است.'}, status=status.HTTP_400_BAD_REQUEST)
user_id = int(user_id)
units_qs = Grade.objects.get(pk=grade_id).units.all() if grade_id else []
units_payload = []
for unit in units_qs:
nodes = list(unit.nodes.all())
node_payload = []
unlock_next = True # اولین نودِ قفل‌نشده در هر واحد، باز می‌مونه
for node in nodes:
node_status = _node_status(node, user_id, unlock_next)
if node_status != 'completed':
unlock_next = False
node_payload.append({'id': str(node.id), 'title': node.title, 'status': node_status})
units_payload.append({'id': str(unit.id), 'title': unit.title, 'nodes': node_payload})
stats = UserStats.objects.filter(user_id=user_id).first()
stats_payload = {
'progressPercent': stats.progress_percent if stats else 0,
'xp': stats.xp if stats else 0,
'coins': stats.coins if stats else 0,
}
data = {'units': units_payload, 'stats': stats_payload}
return Response(HomeResponseSerializer(data).data)
class LessonIntroView(APIView):
"""GET /api/lessons/{id}/intro"""
@extend_schema(responses=LessonIntroResponseSerializer, tags=['Curriculum (content)'])
def get(self, request, lesson_id):
lesson = get_object_or_404(Lesson, pk=lesson_id)
data = {
'lessonId': str(lesson.id),
'title': lesson.title,
'introText': lesson.intro_text,
'mascotAnimation': lesson.mascot_animation,
}
return Response(LessonIntroResponseSerializer(data).data)
class LessonQuestionsView(APIView):
"""GET /api/lessons/{id}/questions"""
@extend_schema(responses=LessonQuestionsResponseSerializer, tags=['Curriculum (content)'])
def get(self, request, lesson_id):
lesson = get_object_or_404(Lesson, pk=lesson_id)
questions = lesson.questions.prefetch_related('choices').all()
data = {'questions': [
{
'id': str(q.id),
'type': q.type,
'prompt': q.prompt,
'options': [{'id': str(c.id), 'value': c.value} for c in q.choices.all()],
}
for q in questions
]}
return Response(LessonQuestionsResponseSerializer(data).data)
class QuestionAnswerView(APIView):
"""POST /api/questions/{id}/answer — ثبت پاسخ"""
# ⚠️ اصلاح امنیتی: قبلاً userId از query/body کلاینت خونده می‌شد که به
# کلاینت اجازه می‌داد به‌جای هر کاربر دیگری پاسخ ثبت کنه (heart/xp کاربر
# دلخواه رو تغییر بده). حالا auth اجباریه و همیشه از request.user.id
# (که از توکن معتبر استخراج شده) استفاده می‌شه.
permission_classes = [IsAuthenticated]
@extend_schema(
request=AnswerRequestSerializer,
responses=AnswerResponseSerializer,
tags=['Curriculum (content)'],
)
def post(self, request, question_id):
question = get_object_or_404(Question, pk=question_id)
serializer = AnswerRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
selected_option_id = serializer.validated_data['selectedOptionId']
choice = get_object_or_404(Choice, pk=selected_option_id, question=question)
correct_choice = question.choices.filter(is_correct=True).first()
user_id = request.user.id
xp_earned = 10 if choice.is_correct else 0
UserAnswer.objects.create(
user_id=user_id,
question=question,
choice=choice,
correct=choice.is_correct,
xp_earned=xp_earned,
)
if not choice.is_correct:
hearts, _ = UserHearts.objects.get_or_create(user_id=user_id)
if hearts.current_hearts > 0:
hearts.current_hearts -= 1
hearts.save(update_fields=['current_hearts'])
hearts_remaining = hearts.current_hearts
else:
hearts = UserHearts.objects.filter(user_id=user_id).first()
hearts_remaining = hearts.current_hearts if hearts else 5
data = {
'correct': choice.is_correct,
'correctOptionId': str(correct_choice.id) if correct_choice else '',
'xpEarned': xp_earned,
'heartsRemaining': hearts_remaining if hearts_remaining is not None else 5,
}
return Response(AnswerResponseSerializer(data).data)
class LessonCompleteView(APIView):
"""POST /api/lessons/{id}/complete — اتمام درس + آمار جدید"""
# ⚠️ اصلاح امنیتی: قبلاً userId از query/body کلاینت خونده می‌شد که به
# کلاینت اجازه می‌داد به‌جای هر کاربر دیگری xp/coins ثبت کنه. حالا auth
# اجباریه و همیشه از request.user.id استفاده می‌شه.
permission_classes = [IsAuthenticated]
@extend_schema(
request=LessonCompleteRequestSerializer,
responses=LessonCompleteResponseSerializer,
tags=['Curriculum (content)'],
)
def post(self, request, lesson_id):
lesson = get_object_or_404(Lesson, pk=lesson_id)
serializer = LessonCompleteRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
correct_count = serializer.validated_data['correctCount']
total_questions = serializer.validated_data['totalQuestions'] or 1
ratio = correct_count / total_questions
stars = 3 if ratio >= 0.9 else 2 if ratio >= 0.6 else 1 if ratio > 0 else 0
potential_xp = correct_count * 10
potential_coins = stars * 5
user_id = request.user.id
# ⚠️ اصلاح: تکمیل هر درس یک‌بارمصرفه — دفعات بعدیِ تکمیل همون درس
# نباید دوباره xp/coins به آمار کاربر اضافه کنه (قبلاً هر بار
# تکمیل، xp_earned/coins_earned رو بدون قید به stats اضافه می‌کرد).
is_first_completion = not UserLessonCompletion.objects.filter(
user_id=user_id, lesson=lesson
).exists()
xp_earned = potential_xp if is_first_completion else 0
coins_earned = potential_coins if is_first_completion else 0
UserLessonCompletion.objects.create(
user_id=user_id,
lesson=lesson,
correct_count=correct_count,
stars=stars,
xp_earned=xp_earned,
coins_earned=coins_earned,
)
stats, _ = UserStats.objects.get_or_create(user_id=user_id)
stats.xp += xp_earned
stats.coins += coins_earned
total_lessons = Lesson.objects.count() or 1
completed_lessons = UserLessonCompletion.objects.filter(
user_id=user_id
).values('lesson_id').distinct().count()
stats.progress_percent = round(min(completed_lessons / total_lessons, 1.0) * 100, 2)
stats.save(update_fields=['xp', 'coins', 'progress_percent'])
stats_payload = {
'progressPercent': stats.progress_percent,
'xp': stats.xp,
'coins': stats.coins,
}
data = {
'stars': stars,
'xpEarned': xp_earned,
'coinsEarned': coins_earned,
'stats': stats_payload,
}
return Response(LessonCompleteResponseSerializer(data).data)

0
discount/__init__.py Normal file
View File

19
discount/admin.py Normal file
View File

@@ -0,0 +1,19 @@
from django.contrib import admin
from .models import DiscountCode, Redemption
class RedemptionInline(admin.TabularInline):
model = Redemption
extra = 0
@admin.register(DiscountCode)
class DiscountCodeAdmin(admin.ModelAdmin):
list_display = ('id', 'code', 'type', 'value', 'max_uses', 'expires_at')
inlines = [RedemptionInline]
@admin.register(Redemption)
class RedemptionAdmin(admin.ModelAdmin):
list_display = ('id', 'discount_code', 'user_id', 'order_id', 'redeemed_at')

6
discount/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class DiscountConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'discount'

View File

@@ -0,0 +1,36 @@
# Generated by Django 5.2.17 on 2026-08-10 12:00
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='DiscountCode',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=30, unique=True)),
('type', models.CharField(choices=[('percent', 'درصدی'), ('fixed', 'مبلغ ثابت')], max_length=10)),
('value', models.PositiveIntegerField()),
('max_uses', models.PositiveIntegerField(default=1)),
('expires_at', models.DateTimeField(blank=True, null=True)),
],
),
migrations.CreateModel(
name='Redemption',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(db_index=True)),
('order_id', models.IntegerField()),
('redeemed_at', models.DateTimeField(auto_now_add=True)),
('discount_code', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='redemptions', to='discount.discountcode')),
],
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.2.17 on 2026-08-10 17:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('discount', '0001_initial'),
]
operations = [
migrations.AddConstraint(
model_name='redemption',
constraint=models.UniqueConstraint(fields=('discount_code', 'user_id'), name='unique_redemption_per_user_per_code'),
),
]

View File

34
discount/models.py Normal file
View File

@@ -0,0 +1,34 @@
from django.db import models
class DiscountCode(models.Model):
TYPE_CHOICES = [('percent', 'درصدی'), ('fixed', 'مبلغ ثابت')]
code = models.CharField(max_length=30, unique=True)
type = models.CharField(max_length=10, choices=TYPE_CHOICES)
value = models.PositiveIntegerField()
max_uses = models.PositiveIntegerField(default=1)
expires_at = models.DateTimeField(null=True, blank=True)
def __str__(self):
return self.code
class Redemption(models.Model):
discount_code = models.ForeignKey(DiscountCode, on_delete=models.CASCADE, related_name='redemptions')
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
order_id = models.IntegerField() # ارجاع سبک به payment.Order
redeemed_at = models.DateTimeField(auto_now_add=True)
class Meta:
# هر کاربر برای یک کد تخفیف حداکثر یک‌بار می‌تونه redeem کنه؛
# قبلاً هیچ محدودیتی در سطح دیتابیس نبود و همراه با نبود چک max_uses
# در RedeemDiscountView، امکان استفاده‌ی نامحدود از یک کد وجود داشت.
constraints = [
models.UniqueConstraint(
fields=['discount_code', 'user_id'], name='unique_redemption_per_user_per_code'
)
]
def __str__(self):
return f"redemption-{self.discount_code_id}-order{self.order_id}"

18
discount/serializers.py Normal file
View File

@@ -0,0 +1,18 @@
from rest_framework import serializers
class ValidateResponseSerializer(serializers.Serializer):
valid = serializers.BooleanField()
code = serializers.CharField(required=False)
type = serializers.CharField(required=False)
value = serializers.IntegerField(required=False)
class RedeemRequestSerializer(serializers.Serializer):
code = serializers.CharField()
orderId = serializers.CharField()
class RedeemResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()
discountAmount = serializers.IntegerField()

3
discount/tests.py Normal file
View File

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

8
discount/urls.py Normal file
View File

@@ -0,0 +1,8 @@
from django.urls import path
from .views import RedeemDiscountView, ValidateDiscountView
urlpatterns = [
path('discount/validate', ValidateDiscountView.as_view(), name='discount-validate'),
path('discount/redeem', RedeemDiscountView.as_view(), name='discount-redeem'),
]

129
discount/views.py Normal file
View File

@@ -0,0 +1,129 @@
from django.db import IntegrityError, transaction
from django.shortcuts import get_object_or_404
from django.utils import timezone
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import DiscountCode, Redemption
from .serializers import (
RedeemRequestSerializer,
RedeemResponseSerializer,
ValidateResponseSerializer,
)
def _is_usable(discount: DiscountCode) -> bool:
return bool(
discount.redemptions.count() < discount.max_uses
and (discount.expires_at is None or discount.expires_at >= timezone.now())
)
class ValidateDiscountView(APIView):
"""GET /api/discount/validate?code= — اعتبارسنجی کد پیش از خرید"""
@extend_schema(
parameters=[OpenApiParameter('code', OpenApiTypes.STR, required=True)],
responses=ValidateResponseSerializer,
tags=['Discount (discount)'],
)
def get(self, request):
code = request.query_params.get('code', '')
discount = DiscountCode.objects.filter(code=code).first()
if not discount or not _is_usable(discount):
return Response(ValidateResponseSerializer({'valid': False}).data)
data = {'valid': True, 'code': discount.code, 'type': discount.type, 'value': discount.value}
return Response(ValidateResponseSerializer(data).data)
class RedeemDiscountView(APIView):
"""
POST /api/discount/redeem — ثبت مصرف کد هنگام خرید
قبلاً فقط ValidateDiscountView چک max_uses/expires_at رو انجام می‌داد؛
خود Redeem هیچ‌کدوم از این محدودیت‌ها رو دوباره چک نمی‌کرد و بین
validate و redeem هم race condition ممکن بود (دو درخواست هم‌زمان هر دو
از سد شمارش رد بشن). اینجا:
- همون شرط‌های usable بودن (max_uses/expires_at) دوباره در لحظه‌ی
redeem چک می‌شه، نه فقط در validate.
- با select_for_update روی DiscountCode، شمارش و ساخت Redemption در
یک تراکنش اتمیک و با قفل ردیف انجام می‌شه تا از over-redemption
هم‌زمان جلوگیری بشه.
- هر کاربر برای یک کد، حداکثر یک‌بار می‌تونه redeem کنه (unique
constraint روی discount_code+user_id در سطح مدل/migration).
طبق سند، این اپ هیچ محاسبه‌ی مبلغی رو دوباره خودش «ذخیره» نمی‌کنه؛ اما
برای پر کردن discountAmount در پاسخ، باید مبلغ سفارش رو از یک جایی
بخونه. چون هیچ اپ دیگه‌ای به این اپ ارجاع نمی‌ده (نه برعکس)، اینجا فقط
یک خواندن با id از payment.models.Order انجام می‌شه — دقیقاً همون
الگویی که progress/views.py برای خوندن نام کاربر از accounts.models.User
استفاده کرده (import مستقیم + فیلتر با id، نه FK واقعی و نه join).
"""
permission_classes = [IsAuthenticated]
@extend_schema(request=RedeemRequestSerializer, responses=RedeemResponseSerializer,
tags=['Discount (discount)'])
def post(self, request):
serializer = RedeemRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
order_id = int(v['orderId'])
from payment.models import Order
# ⚠️ اصلاح امنیتی: قبلاً مالکیتِ سفارش چک نمی‌شد، پس هر کاربر
# لاگین‌کرده‌ای می‌تونست با حدس‌زدن یک orderId، کد تخفیف رو روی
# سفارشِ کاربر دیگری اعمال کنه. حالا سفارش باید متعلق به همون
# کاربر احرازهویت‌شده باشه.
order = get_object_or_404(Order, pk=order_id, user_id=request.user.id)
try:
with transaction.atomic():
discount = get_object_or_404(
DiscountCode.objects.select_for_update(), code=v['code']
)
if not _is_usable(discount):
raise ValidationError({'code': 'این کد دیگر معتبر نیست یا سقف استفاده‌اش پر شده.'})
if discount.redemptions.filter(user_id=request.user.id).exists():
raise ValidationError({'code': 'شما قبلاً این کد را استفاده کرده‌اید.'})
# فقط سفارش‌های در انتظار پرداخت می‌تونن تخفیف بگیرن؛ سفارش
# پرداخت‌شده/ناموفق نباید عقب‌گرد تغییر کنه.
if order.status != 'pending':
raise ValidationError({'code': 'این سفارش دیگر قابل اعمال تخفیف نیست.'})
order_locked = Order.objects.select_for_update().get(pk=order.id)
if discount.type == 'percent':
discount_amount = order_locked.amount * discount.value // 100
else:
discount_amount = min(discount.value, order_locked.amount)
Redemption.objects.create(
discount_code=discount,
user_id=request.user.id,
order_id=order_id,
)
# ⚠️ اصلاح: قبلاً discountAmount فقط محاسبه و در پاسخ
# برگردونده می‌شد ولی هیچ‌وقت روی خودِ Order اعمال نمی‌شد،
# یعنی جریان واقعی پرداخت مبلغ تخفیف‌نخورده رو دریافت می‌کرد.
# حالا مبلغ سفارش واقعاً کم می‌شه تا مرحله‌ی پرداخت همین
# مبلغ تخفیف‌خورده رو ببینه.
order_locked.amount = max(order_locked.amount - discount_amount, 0)
order_locked.save(update_fields=['amount'])
except IntegrityError:
# fallback در برابر race condition روی unique constraint، حتی
# اگه به هر دلیلی قفل select_for_update رد بشه.
raise ValidationError({'code': 'این کد دیگر معتبر نیست یا سقف استفاده‌اش پر شده.'})
data = {'success': True, 'discountAmount': discount_amount}
return Response(RedeemResponseSerializer(data).data)

0
family/__init__.py Normal file
View File

33
family/admin.py Normal file
View File

@@ -0,0 +1,33 @@
from django.contrib import admin
from .models import FamilyLink, ParentalSettings, WeeklyReportLog
class ParentalSettingsInline(admin.StackedInline):
model = ParentalSettings
extra = 0
class WeeklyReportLogInline(admin.TabularInline):
model = WeeklyReportLog
extra = 0
readonly_fields = ('sent_at',)
@admin.register(FamilyLink)
class FamilyLinkAdmin(admin.ModelAdmin):
list_display = ('id', 'parent_user_id', 'child_user_id', 'status', 'invite_code', 'invited_at', 'accepted_at')
list_filter = ('status',)
search_fields = ('invite_code', 'child_phone')
inlines = [ParentalSettingsInline, WeeklyReportLogInline]
@admin.register(ParentalSettings)
class ParentalSettingsAdmin(admin.ModelAdmin):
list_display = ('id', 'family_link', 'daily_limit_minutes', 'quiet_hours_start', 'quiet_hours_end')
@admin.register(WeeklyReportLog)
class WeeklyReportLogAdmin(admin.ModelAdmin):
list_display = ('id', 'family_link', 'week_start', 'sent_at', 'opened_at')
list_filter = ('week_start',)

6
family/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class FamilyConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'family'

View File

@@ -0,0 +1,55 @@
# Generated by Django 5.2.17 on 2026-08-10 12:00
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='FamilyLink',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('parent_user_id', models.IntegerField(db_index=True)),
('child_user_id', models.IntegerField(blank=True, db_index=True, null=True)),
('child_phone', models.CharField(blank=True, max_length=15)),
('invite_code', models.CharField(max_length=10, unique=True)),
('status', models.CharField(choices=[('pending', 'در انتظار'), ('accepted', 'پذیرفته‌شده')], default='pending', max_length=10)),
('invited_at', models.DateTimeField(auto_now_add=True)),
('accepted_at', models.DateTimeField(blank=True, null=True)),
],
options={
'ordering': ['-invited_at'],
},
),
migrations.CreateModel(
name='ParentalSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('daily_limit_minutes', models.PositiveIntegerField(default=60)),
('quiet_hours_start', models.TimeField(blank=True, null=True)),
('quiet_hours_end', models.TimeField(blank=True, null=True)),
('family_link', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='settings', to='family.familylink')),
],
),
migrations.CreateModel(
name='WeeklyReportLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('week_start', models.DateField()),
('sent_at', models.DateTimeField(blank=True, null=True)),
('opened_at', models.DateTimeField(blank=True, null=True)),
('family_link', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='weekly_reports', to='family.familylink')),
],
options={
'ordering': ['-week_start'],
'unique_together': {('family_link', 'week_start')},
},
),
]

View File

55
family/models.py Normal file
View File

@@ -0,0 +1,55 @@
from django.db import models
class FamilyLink(models.Model):
"""
اتصال حساب والد به یک فرزند. طبق سند معماری، این اپ فقط به accounts.User
ارجاع سبک (parent_user_id / child_user_id) می‌ده، نه FK واقعی.
⚠️ افزوده نسبت به سند اولیه: سند models فقط parent_user_id/child_user_id
را مشخص کرده بود، ولی برای پیاده‌سازی واقعی جریان «دعوت با کد» (طبق سند
Endpointها: POST /api/family/link/invite و /api/family/link/accept) باید
خود کد دعوت و شماره‌ی فرزندی که هنوز ثبت‌نام نکرده جایی نگه داشته بشه؛
دقیقاً همون الگویی که accounts.OTPCode برای نگه‌داشتن کد یک‌بارمصرف
استفاده می‌کنه. اگه این دو فیلد قرار بود جای دیگه‌ای (مثلاً یک مدل
Invite جدا) نگه داشته بشن، می‌شه بعداً جابه‌جا کرد.
"""
STATUS_CHOICES = [('pending', 'در انتظار'), ('accepted', 'پذیرفته‌شده')]
parent_user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
child_user_id = models.IntegerField(null=True, blank=True, db_index=True) # ارجاع سبک به accounts.User
child_phone = models.CharField(max_length=15, blank=True) # <-- افزوده شده
invite_code = models.CharField(max_length=10, unique=True) # <-- افزوده شده
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending')
invited_at = models.DateTimeField(auto_now_add=True)
accepted_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-invited_at']
def __str__(self):
return f"famlink-{self.pk}-parent{self.parent_user_id}-child{self.child_user_id or '?'}"
class ParentalSettings(models.Model):
family_link = models.OneToOneField(FamilyLink, on_delete=models.CASCADE, related_name='settings')
daily_limit_minutes = models.PositiveIntegerField(default=60)
quiet_hours_start = models.TimeField(null=True, blank=True)
quiet_hours_end = models.TimeField(null=True, blank=True)
def __str__(self):
return f"settings-famlink{self.family_link_id}"
class WeeklyReportLog(models.Model):
family_link = models.ForeignKey(FamilyLink, on_delete=models.CASCADE, related_name='weekly_reports')
week_start = models.DateField()
sent_at = models.DateTimeField(null=True, blank=True)
opened_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-week_start']
unique_together = [('family_link', 'week_start')]
def __str__(self):
return f"weekly-famlink{self.family_link_id}-{self.week_start}"

51
family/serializers.py Normal file
View File

@@ -0,0 +1,51 @@
from rest_framework import serializers
class FamilyLinkInviteRequestSerializer(serializers.Serializer):
childPhone = serializers.CharField(max_length=15)
class FamilyLinkInviteResponseSerializer(serializers.Serializer):
familyLinkId = serializers.CharField()
inviteCode = serializers.CharField()
status = serializers.CharField()
class FamilyLinkAcceptRequestSerializer(serializers.Serializer):
inviteCode = serializers.CharField(max_length=10)
class FamilyLinkAcceptResponseSerializer(serializers.Serializer):
familyLinkId = serializers.CharField()
status = serializers.CharField()
acceptedAt = serializers.DateTimeField(allow_null=True)
class FamilyLinkStatusResponseSerializer(serializers.Serializer):
familyLinkId = serializers.CharField()
status = serializers.CharField()
childName = serializers.CharField(allow_blank=True)
class FamilySettingsResponseSerializer(serializers.Serializer):
dailyLimitMinutes = serializers.IntegerField()
quietHoursStart = serializers.TimeField(allow_null=True)
quietHoursEnd = serializers.TimeField(allow_null=True)
class FamilySettingsUpdateRequestSerializer(serializers.Serializer):
dailyLimitMinutes = serializers.IntegerField(required=False, min_value=0)
quietHoursStart = serializers.TimeField(required=False, allow_null=True)
quietHoursEnd = serializers.TimeField(required=False, allow_null=True)
class FamilySettingsUpdateResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()
class FamilyWeeklyReportResponseSerializer(serializers.Serializer):
weekStart = serializers.DateField()
xpEarned = serializers.IntegerField()
lessonsCompleted = serializers.IntegerField()
currentStreak = serializers.IntegerField()
openedAt = serializers.DateTimeField(allow_null=True)

3
family/tests.py Normal file
View File

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

17
family/urls.py Normal file
View File

@@ -0,0 +1,17 @@
from django.urls import path
from .views import (
FamilyLinkAcceptView,
FamilyLinkInviteView,
FamilyLinkStatusView,
FamilySettingsView,
FamilyWeeklyReportView,
)
urlpatterns = [
path('family/link/invite', FamilyLinkInviteView.as_view(), name='family-link-invite'),
path('family/link/accept', FamilyLinkAcceptView.as_view(), name='family-link-accept'),
path('family/link', FamilyLinkStatusView.as_view(), name='family-link-status'),
path('family/settings', FamilySettingsView.as_view(), name='family-settings'),
path('family/weekly-report', FamilyWeeklyReportView.as_view(), name='family-weekly-report'),
]

241
family/views.py Normal file
View File

@@ -0,0 +1,241 @@
from datetime import timedelta
from django.db.models import Q, Sum
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.utils.crypto import get_random_string
from drf_spectacular.utils import extend_schema
from rest_framework.exceptions import NotFound, PermissionDenied
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import FamilyLink, ParentalSettings, WeeklyReportLog
from .serializers import (
FamilyLinkAcceptRequestSerializer,
FamilyLinkAcceptResponseSerializer,
FamilyLinkInviteRequestSerializer,
FamilyLinkInviteResponseSerializer,
FamilyLinkStatusResponseSerializer,
FamilySettingsResponseSerializer,
FamilySettingsUpdateRequestSerializer,
FamilySettingsUpdateResponseSerializer,
FamilyWeeklyReportResponseSerializer,
)
def _week_start(dt):
"""دوشنبه‌ی هفته‌ی جاری (ISO weekday) برای dt داده‌شده."""
return (dt - timedelta(days=dt.weekday())).date()
class FamilyLinkInviteView(APIView):
"""POST /api/family/link/invite — دعوت فرزند توسط والد (تولید کد اتصال)"""
permission_classes = [IsAuthenticated]
@extend_schema(
request=FamilyLinkInviteRequestSerializer,
responses=FamilyLinkInviteResponseSerializer,
tags=['Family (family)'],
)
def post(self, request):
serializer = FamilyLinkInviteRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
# اگه دعوت pending برای همین شماره از قبل هست، به‌جای رکورد تکراری
# (مثل الگوی idempotency در payment/services.py) همون رو برمی‌گردونیم.
link = FamilyLink.objects.filter(
parent_user_id=request.user.id, child_phone=v['childPhone'], status='pending'
).first()
if not link:
code = get_random_string(6, allowed_chars='0123456789')
while FamilyLink.objects.filter(invite_code=code).exists():
code = get_random_string(6, allowed_chars='0123456789')
link = FamilyLink.objects.create(
parent_user_id=request.user.id,
child_phone=v['childPhone'],
invite_code=code,
)
data = {'familyLinkId': str(link.pk), 'inviteCode': link.invite_code, 'status': link.status}
return Response(FamilyLinkInviteResponseSerializer(data).data)
class FamilyLinkAcceptView(APIView):
"""POST /api/family/link/accept — تأیید دعوت توسط فرزند"""
permission_classes = [IsAuthenticated]
@extend_schema(
request=FamilyLinkAcceptRequestSerializer,
responses=FamilyLinkAcceptResponseSerializer,
tags=['Family (family)'],
)
def post(self, request):
serializer = FamilyLinkAcceptRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
link = get_object_or_404(FamilyLink, invite_code=v['inviteCode'], status='pending')
# ⚠️ اصلاح امنیتی: قبلاً هر کاربر لاگین‌کرده‌ای با دونستنِ inviteCode
# می‌تونست دعوتِ یک والد رو claim کنه، حتی اگه اون دعوت اصلاً برای
# شماره‌ی او نبود (مثلاً کد رو حدس بزنه یا از جای دیگه‌ای ببینه).
# حالا اگه دعوت برای یک شماره‌ی مشخص صادر شده باشه، فقط کاربری که
# شماره‌اش با child_phone یکی باشه می‌تونه اون رو accept کنه.
if link.child_phone and link.child_phone != request.user.phone:
raise PermissionDenied('این دعوت متعلق به شماره‌ی دیگری است.')
link.child_user_id = request.user.id
link.status = 'accepted'
link.accepted_at = timezone.now()
link.save(update_fields=['child_user_id', 'status', 'accepted_at'])
# رویداد FamilyLinkAccepted — طبق سند معماری، Notifications فقط
# event-listener‌ه؛ دقیقاً همون الگوی notify() که در
# support/admin.py هنگام پاسخ تیکت صدا زده می‌شه.
from notifications.services import notify
notify(
user_id=link.parent_user_id,
type='family_link_accepted',
title='فرزند شما دعوت را پذیرفت',
related_id=link.id,
)
data = {'familyLinkId': str(link.pk), 'status': link.status, 'acceptedAt': link.accepted_at}
return Response(FamilyLinkAcceptResponseSerializer(data).data)
class FamilyLinkStatusView(APIView):
"""GET /api/family/link — وضعیت اتصال والد-فرزند (برای هر دو طرف)"""
permission_classes = [IsAuthenticated]
@extend_schema(responses=FamilyLinkStatusResponseSerializer, tags=['Family (family)'])
def get(self, request):
link = FamilyLink.objects.filter(
Q(parent_user_id=request.user.id) | Q(child_user_id=request.user.id)
).first()
if not link:
raise NotFound('اتصال خانوادگی پیدا نشد.')
child_name = ''
if link.child_user_id:
from accounts.models import User as AccountUser
child = AccountUser.objects.filter(id=link.child_user_id).first()
child_name = child.name if child else ''
data = {'familyLinkId': str(link.pk), 'status': link.status, 'childName': child_name}
return Response(FamilyLinkStatusResponseSerializer(data).data)
class FamilySettingsView(APIView):
"""GET/PATCH /api/family/settings — تنظیمات کنترل والدین"""
permission_classes = [IsAuthenticated]
def _get_settings(self, request):
link = get_object_or_404(FamilyLink, parent_user_id=request.user.id, status='accepted')
settings_obj, _ = ParentalSettings.objects.get_or_create(family_link=link)
return settings_obj
@extend_schema(responses=FamilySettingsResponseSerializer, tags=['Family (family)'])
def get(self, request):
settings_obj = self._get_settings(request)
data = {
'dailyLimitMinutes': settings_obj.daily_limit_minutes,
'quietHoursStart': settings_obj.quiet_hours_start,
'quietHoursEnd': settings_obj.quiet_hours_end,
}
return Response(FamilySettingsResponseSerializer(data).data)
@extend_schema(
request=FamilySettingsUpdateRequestSerializer,
responses=FamilySettingsUpdateResponseSerializer,
tags=['Family (family)'],
)
def patch(self, request):
settings_obj = self._get_settings(request)
serializer = FamilySettingsUpdateRequestSerializer(data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
update_fields = []
if 'dailyLimitMinutes' in v:
settings_obj.daily_limit_minutes = v['dailyLimitMinutes']
update_fields.append('daily_limit_minutes')
if 'quietHoursStart' in v:
settings_obj.quiet_hours_start = v['quietHoursStart']
update_fields.append('quiet_hours_start')
if 'quietHoursEnd' in v:
settings_obj.quiet_hours_end = v['quietHoursEnd']
update_fields.append('quiet_hours_end')
if update_fields:
settings_obj.save(update_fields=update_fields)
return Response(FamilySettingsUpdateResponseSerializer({'success': True}).data)
class FamilyWeeklyReportView(APIView):
"""
GET /api/family/weekly-report — آخرین گزارش هفتگی
طبق سند معماری، این گزارش خودش داده تکراری نگه نمی‌داره و از API
اپ‌های Progress و Curriculum خونده می‌شه؛ چون این پروژه یک مونولیت
جنگویی‌ه، همون «خوندن» با import مستقیم مدل‌های آن دو اپ پیاده‌سازی
شده — دقیقاً همون الگویی که progress/views.py (LeaderboardView) برای
خوندن نام کاربر از accounts.User استفاده می‌کنه.
"""
permission_classes = [IsAuthenticated]
@extend_schema(responses=FamilyWeeklyReportResponseSerializer, tags=['Family (family)'])
def get(self, request):
link = get_object_or_404(FamilyLink, parent_user_id=request.user.id, status='accepted')
week_start = _week_start(timezone.now())
log, created = WeeklyReportLog.objects.get_or_create(
family_link=link, week_start=week_start, defaults={'sent_at': timezone.now()}
)
if created:
from notifications.services import notify
notify(
user_id=link.parent_user_id,
type='weekly_report_ready',
title='گزارش هفتگی فرزند شما آماده است',
related_id=log.id,
)
from content.models import UserLessonCompletion
from progress.models import StreakRecord
completions = UserLessonCompletion.objects.filter(
user_id=link.child_user_id, created_at__date__gte=week_start
)
xp_earned = completions.aggregate(total=Sum('xp_earned'))['total'] or 0
lessons_completed = completions.count()
streak = StreakRecord.objects.filter(user_id=link.child_user_id).first()
current_streak = streak.current_streak if streak else 0
if not log.opened_at:
log.opened_at = timezone.now()
log.save(update_fields=['opened_at'])
data = {
'weekStart': week_start,
'xpEarned': xp_earned,
'lessonsCompleted': lessons_completed,
'currentStreak': current_streak,
'openedAt': log.opened_at,
}
return Response(FamilyWeeklyReportResponseSerializer(data).data)

22
manage.py Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

View File

10
notifications/admin.py Normal file
View File

@@ -0,0 +1,10 @@
from django.contrib import admin
from .models import Notification
@admin.register(Notification)
class NotificationAdmin(admin.ModelAdmin):
list_display = ('id', 'user_id', 'type', 'title', 'is_read', 'created_at')
list_filter = ('type', 'is_read')
search_fields = ('title', 'body')

6
notifications/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class NotificationsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'notifications'

View File

@@ -0,0 +1,37 @@
# Generated by Django 5.2.17 on 2026-08-10 12:00
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Notification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(db_index=True)),
('type', models.CharField(choices=[
('xp_earned', 'امتیاز گرفتی'),
('lesson_completed', 'درس تکمیل شد'),
('order_paid', 'پرداخت موفق'),
('ticket_replied', 'پاسخ تیکت'),
('family_link_accepted', 'اتصال خانواده تأیید شد'),
('weekly_report_ready', 'گزارش هفتگی آماده شد'),
], max_length=30)),
('title', models.CharField(max_length=150)),
('body', models.CharField(blank=True, max_length=300)),
('related_id', models.IntegerField(blank=True, null=True)),
('is_read', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['-created_at'],
},
),
]

View File

30
notifications/models.py Normal file
View File

@@ -0,0 +1,30 @@
from django.db import models
class Notification(models.Model):
# انواع رویدادهایی که این اپ فقط بهشون گوش می‌ده (event-listener)؛
# خودش تولیدکننده‌ی رویداد نیست. طبق سند معماری:
# LessonCompleted/XPEarned (Progress), OrderPaid (Payment),
# TicketReplied (Support), FamilyLinkAccepted/WeeklyReportReady (Family)
TYPE_CHOICES = [
('xp_earned', 'امتیاز گرفتی'),
('lesson_completed', 'درس تکمیل شد'),
('order_paid', 'پرداخت موفق'),
('ticket_replied', 'پاسخ تیکت'),
('family_link_accepted', 'اتصال خانواده تأیید شد'),
('weekly_report_ready', 'گزارش هفتگی آماده شد'),
]
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
type = models.CharField(max_length=30, choices=TYPE_CHOICES)
title = models.CharField(max_length=150)
body = models.CharField(max_length=300, blank=True)
related_id = models.IntegerField(null=True, blank=True) # id رکورد رویداد منبع، بدون FK واقعی
is_read = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f"notif-{self.id}-{self.type}-user{self.user_id}"

View File

@@ -0,0 +1,21 @@
from rest_framework import serializers
from .models import Notification
class NotificationItemSerializer(serializers.ModelSerializer):
id = serializers.IntegerField()
isRead = serializers.BooleanField(source='is_read')
class Meta:
model = Notification
fields = ['id', 'type', 'title', 'isRead']
class NotificationListResponseSerializer(serializers.Serializer):
items = NotificationItemSerializer(many=True)
unreadCount = serializers.IntegerField()
class MarkReadResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()

27
notifications/services.py Normal file
View File

@@ -0,0 +1,27 @@
"""
اپ Notifications برخلاف بقیه‌ی اپ‌ها به هیچ اپی رفرنس سبک نمی‌ده و خودش
تولیدکننده‌ی رویداد نیست؛ فقط event-listener‌ه (طبق سند معماری داده).
چون این پروژه یک مونولیت جنگویی‌ه، «گوش دادن به رویداد» با یک تابع کمکی
پیاده‌سازی شده که سایر اپ‌ها (Progress، Payment، Support، Family) مستقیماً
از این ماژول import و صدا می‌زنن — دقیقاً همون الگوی call_payment_gateway
در payment/services.py و create_order که سرور-به-سرور از subscription
صدا زده می‌شه.
"""
from .models import Notification
def notify(*, user_id: int, type: str, title: str, body: str = '', related_id: int | None = None) -> Notification:
"""
ثبت یک اعلان جدید برای کاربر. نمونه‌ی استفاده از اپ‌های دیگه:
from notifications.services import notify
notify(user_id=order.user_id, type='order_paid',
title='پرداخت با موفقیت انجام شد', related_id=order.id)
"""
return Notification.objects.create(
user_id=user_id,
type=type,
title=title,
body=body,
related_id=related_id,
)

3
notifications/tests.py Normal file
View File

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

8
notifications/urls.py Normal file
View File

@@ -0,0 +1,8 @@
from django.urls import path
from .views import NotificationListView, NotificationMarkReadView
urlpatterns = [
path('notifications', NotificationListView.as_view(), name='notifications-list'),
path('notifications/<int:id>/read', NotificationMarkReadView.as_view(), name='notifications-mark-read'),
]

37
notifications/views.py Normal file
View File

@@ -0,0 +1,37 @@
from django.shortcuts import get_object_or_404
from drf_spectacular.utils import extend_schema
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import Notification
from .serializers import MarkReadResponseSerializer, NotificationListResponseSerializer
class NotificationListView(APIView):
"""GET /api/notifications — فهرست اعلان‌های کاربر (جدیدترین اول)"""
permission_classes = [IsAuthenticated]
@extend_schema(responses=NotificationListResponseSerializer, tags=['Notifications (notifications)'])
def get(self, request):
qs = Notification.objects.filter(user_id=request.user.id)
unread_count = qs.filter(is_read=False).count()
data = {'items': list(qs), 'unreadCount': unread_count}
return Response(NotificationListResponseSerializer(data).data)
class NotificationMarkReadView(APIView):
"""PATCH /api/notifications/{id}/read — علامت‌گذاری یک اعلان به‌عنوان خوانده‌شده"""
permission_classes = [IsAuthenticated]
@extend_schema(responses=MarkReadResponseSerializer, tags=['Notifications (notifications)'])
def patch(self, request, id):
notification = get_object_or_404(Notification, pk=id, user_id=request.user.id)
if not notification.is_read:
notification.is_read = True
notification.save(update_fields=['is_read'])
return Response(MarkReadResponseSerializer({'success': True}).data)

0
payment/__init__.py Normal file
View File

21
payment/admin.py Normal file
View File

@@ -0,0 +1,21 @@
from django.contrib import admin
from .models import Order, Transaction
class TransactionInline(admin.TabularInline):
model = Transaction
extra = 0
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ('id', 'user_id', 'subscription_id', 'amount', 'currency', 'status', 'created_at')
list_filter = ('status', 'currency')
inlines = [TransactionInline]
@admin.register(Transaction)
class TransactionAdmin(admin.ModelAdmin):
list_display = ('id', 'order', 'gateway', 'status', 'paid_at')
list_filter = ('gateway', 'status')

6
payment/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class PaymentConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'payment'

View File

@@ -0,0 +1,46 @@
# Generated by Django 5.2.17 on 2026-08-10 12:00
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Order',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(db_index=True)),
('subscription_id', models.IntegerField(blank=True, null=True)),
('amount', models.PositiveIntegerField()),
('currency', models.CharField(default='IRT', max_length=10)),
('status', models.CharField(choices=[('pending', 'در انتظار'), ('paid', 'پرداخت‌شده'), ('failed', 'ناموفق')], default='pending', max_length=10)),
('idempotency_key', models.CharField(max_length=100, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Transaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('gateway', models.CharField(default='zarinpal', max_length=30)),
('gateway_ref_id', models.CharField(blank=True, max_length=100)),
('status', models.CharField(max_length=30)),
('paid_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to='payment.order')),
],
options={
'ordering': ['created_at'],
},
),
]

View File

38
payment/models.py Normal file
View File

@@ -0,0 +1,38 @@
from django.db import models
class Order(models.Model):
"""
تنها اپی که واقعاً پول جابه‌جا می‌کنه. عمداً از Subscription جدا شده
چون منطق و ریسکش فرق داره (درگاه، retry، idempotency).
"""
STATUS_CHOICES = [('pending', 'در انتظار'), ('paid', 'پرداخت‌شده'), ('failed', 'ناموفق')]
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
subscription_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به subscription.Subscription
amount = models.PositiveIntegerField()
currency = models.CharField(max_length=10, default='IRT')
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending')
idempotency_key = models.CharField(max_length=100, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f"order-{self.pk}-user{self.user_id}"
class Transaction(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='transactions')
gateway = models.CharField(max_length=30, default='zarinpal')
gateway_ref_id = models.CharField(max_length=100, blank=True)
status = models.CharField(max_length=30)
paid_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['created_at']
def __str__(self):
return f"txn-{self.pk}-order{self.order_id}"

31
payment/permissions.py Normal file
View File

@@ -0,0 +1,31 @@
from django.conf import settings
from rest_framework.permissions import BasePermission
class HasInternalServiceKey(BasePermission):
"""
برای endpointهایی مثل POST /api/payment/orders که طبق سند فقط قراره
سرور-به-سرور (از اپ Subscription) صدا زده بشن، نه از یک کلاینت دلخواه.
درخواست باید هدر X-Internal-Key رو برابر settings.INTERNAL_SERVICE_KEY
بفرسته؛ در غیر این صورت 403 برمی‌گرده.
"""
message = 'این endpoint فقط برای فراخوانی داخلی/سرور-به-سرور است.'
def has_permission(self, request, view):
provided = request.headers.get('X-Internal-Key', '')
return bool(provided) and provided == settings.INTERNAL_SERVICE_KEY
class HasWebhookSecret(BasePermission):
"""
برای POST /api/payment/webhook: باید از سمت درگاه پرداخت بیاد، نه یک
کلاینت دلخواه. تا زمان اتصال درگاه واقعی (که خودش امضای HMAC داره)،
یک shared-secret ساده از طریق هدر X-Webhook-Secret بررسی می‌شه.
"""
message = 'امضای وب‌هوک نامعتبر است.'
def has_permission(self, request, view):
provided = request.headers.get('X-Webhook-Secret', '')
return bool(provided) and provided == settings.PAYMENT_WEBHOOK_SECRET

46
payment/serializers.py Normal file
View File

@@ -0,0 +1,46 @@
from rest_framework import serializers
class CreateOrderRequestSerializer(serializers.Serializer):
# این فراخوانی سرور-به-سرور از اپ Subscription‌ه، نه از کلاینت؛ به همین
# دلیل userId اینجا مستقیماً در بدنه ارسال می‌شه (نه از توکن استخراج).
userId = serializers.CharField()
amount = serializers.IntegerField()
currency = serializers.CharField(required=False, default='IRT')
subscriptionId = serializers.CharField(required=False, allow_null=True, allow_blank=True)
class CreateOrderResponseSerializer(serializers.Serializer):
orderId = serializers.CharField()
status = serializers.CharField()
paymentUrl = serializers.CharField()
class WebhookRequestSerializer(serializers.Serializer):
# کال‌بک از سمت درگاه پرداخت میاد، نه از اپ
orderId = serializers.CharField()
gateway = serializers.CharField(required=False, default='zarinpal')
gatewayRefId = serializers.CharField(required=False, allow_blank=True)
# ⚠️ اصلاح امنیتی: قبلاً status یک CharField آزاد بود و هر مقداری
# (حتی مقادیر پشتیبانی‌نشده/اشتباه) رو مستقیم روی Order ذخیره می‌کرد.
# حالا فقط دو وضعیت نهاییِ معتبرِ کال‌بک درگاه پذیرفته می‌شه؛ 'pending'
# وضعیت اولیه‌ی داخلیه و نباید از بیرون توسط webhook قابل‌تنظیم باشه.
status = serializers.ChoiceField(choices=['paid', 'failed'])
class WebhookResponseSerializer(serializers.Serializer):
received = serializers.BooleanField()
class TransactionSerializer(serializers.Serializer):
id = serializers.CharField()
gateway = serializers.CharField()
status = serializers.CharField()
paidAt = serializers.DateTimeField(allow_null=True)
class OrderDetailResponseSerializer(serializers.Serializer):
orderId = serializers.CharField()
status = serializers.CharField()
amount = serializers.IntegerField()
transactions = TransactionSerializer(many=True)

98
payment/services.py Normal file
View File

@@ -0,0 +1,98 @@
"""
منطق مشترک اپ Payment که هم از views.py همین اپ و هم به‌صورت سرور-به-سرور
از اپ‌های دیگه (مثل subscription) صدا زده می‌شه. چون این پروژه یک مونولیت
جنگویی‌ه (نه میکروسرویس واقعی)، «فراخوانی سرور-به-سرور» با یک import مستقیم
پیاده‌سازی شده، نه یک HTTP call به خودمون.
"""
from django.db import IntegrityError
from django.db import transaction as db_transaction
from django.utils.crypto import get_random_string
from rest_framework.exceptions import APIException
from .models import Order
class IdempotencyKeyConflict(APIException):
"""
⚠️ اصلاح امنیتی: قبلاً create_order فقط با idempotency_key سفارش رو
get_or_create می‌کرد، بدون بررسی اینکه صاحب سفارش موجود همون کاربرِ
درخواست‌دهنده‌ست یا پارامترها (amount/currency/subscription) یکی‌ان.
این یعنی یک کاربر می‌تونست با حدس زدن/استفاده‌ی مجدد از یک
Idempotency-Key، سفارشِ کاربر دیگه‌ای رو برگردونه/استفاده کنه، یا با
همون کلید ولی amount متفاوت، رفتار غیرمنتظره بگیره. حالا این حالت‌ها
409 Conflict برمی‌گردونن.
"""
status_code = 409
default_detail = 'این Idempotency-Key قبلاً برای درخواست دیگری استفاده شده است.'
default_code = 'idempotency_key_conflict'
def _ensure_idempotent_match(order: Order, *, user_id: int, amount: int, currency: str,
subscription_id: int | None) -> None:
if order.user_id != user_id:
raise IdempotencyKeyConflict(
'این Idempotency-Key متعلق به کاربر دیگری است و نمی‌تواند استفاده شود.'
)
if (order.amount != amount or order.currency != currency
or order.subscription_id != subscription_id):
raise IdempotencyKeyConflict(
'این Idempotency-Key قبلاً با پارامترهای متفاوتی استفاده شده است.'
)
def call_payment_gateway(order: Order) -> str:
"""
استاب فراخوانی درگاه پرداخت واقعی (zarinpal و غیره) — دقیقاً مثل الگوی
generate_tutor_answer در ai_tutor/views.py. فعلاً فقط یک paymentUrl
قطعی و ساختگی برمی‌گردونه تا کل جریان (ساخت سفارش → پرداخت → webhook)
بدون نیاز به کلید API واقعی درگاه، از طریق Swagger قابل تست باشه.
برای اتصال واقعی، این تابع را با فراخوانی HTTP به API درگاه (مثلاً
zarinpal.com/pg/rest/WebGate/PaymentRequest.json) جایگزین کنید.
"""
return f'https://payment.gateway/pay/{order.id}'
def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
subscription_id: int | None = None, idempotency_key: str | None = None):
"""
ساخت سفارش با پشتیبانی از Idempotency-Key (بخش ۴.۳ سند API): اگه سفارشی
با همین کلید قبلاً ساخته شده باشه، همون رکورد برگردونده می‌شه، نه یک
رکورد تکراری (مثلاً وقتی کلاینت درخواست رو به‌خاطر timeout دوباره می‌فرسته)
— اما فقط وقتی سفارشِ موجود واقعاً متعلق به همین کاربر و همین پارامترهاست
(وگرنه IdempotencyKeyConflict می‌گیره؛ نگاه کنید به توضیح بالای این فایل).
"""
if not idempotency_key:
idempotency_key = get_random_string(32)
existing = Order.objects.filter(idempotency_key=idempotency_key).first()
if existing is not None:
_ensure_idempotent_match(
existing, user_id=user_id, amount=amount, currency=currency,
subscription_id=subscription_id,
)
payment_url = call_payment_gateway(existing)
return existing, payment_url
try:
with db_transaction.atomic():
order = Order.objects.create(
user_id=user_id,
subscription_id=subscription_id,
amount=amount,
currency=currency,
status='pending',
idempotency_key=idempotency_key,
)
except IntegrityError:
# race condition: درخواست هم‌زمان دیگه‌ای با همین کلید زودتر ساخت.
order = Order.objects.filter(idempotency_key=idempotency_key).first()
if order is None:
raise
_ensure_idempotent_match(
order, user_id=user_id, amount=amount, currency=currency,
subscription_id=subscription_id,
)
payment_url = call_payment_gateway(order)
return order, payment_url

3
payment/tests.py Normal file
View File

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

9
payment/urls.py Normal file
View File

@@ -0,0 +1,9 @@
from django.urls import path
from .views import CreateOrderView, OrderDetailView, PaymentWebhookView
urlpatterns = [
path('payment/orders', CreateOrderView.as_view(), name='payment-create-order'),
path('payment/webhook', PaymentWebhookView.as_view(), name='payment-webhook'),
path('payment/orders/<int:order_id>', OrderDetailView.as_view(), name='payment-order-detail'),
]

174
payment/views.py Normal file
View File

@@ -0,0 +1,174 @@
from django.db import transaction
from django.shortcuts import get_object_or_404
from django.utils import timezone
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import Order, Transaction
from .permissions import HasInternalServiceKey, HasWebhookSecret
from .serializers import (
CreateOrderRequestSerializer,
CreateOrderResponseSerializer,
OrderDetailResponseSerializer,
WebhookRequestSerializer,
WebhookResponseSerializer,
)
from .services import create_order
class CreateOrderView(APIView):
"""
POST /api/payment/orders — ساخت سفارش (فراخوانی‌شده از اپ Subscription)
طبق سند API، این یک فراخوانی سرور-به-سرور از اپ Subscription‌ه، نه از
کلاینت. در عمل subscription/views.py مستقیماً تابع create_order رو
import و صدا می‌زنه، پس این endpoint برای جریان عادی از بیرون صدا زده
نمی‌شه؛ برای همین به‌جای AllowAny یک shared-secret داخلی
(HasInternalServiceKey، هدر X-Internal-Key) لازمه، وگرنه هر کلاینتی
می‌تونست با یک userId دلخواه سفارش بسازه.
"""
permission_classes = [HasInternalServiceKey]
# ⚠️ افزوده شده: هدرهای موردنیاز (X-Internal-Key, Idempotency-Key) قبلاً
# مستند نشده بودن و از Swagger UI اصلاً قابل ست‌کردن/تست‌کردن نبودن.
@extend_schema(
request=CreateOrderRequestSerializer,
responses=CreateOrderResponseSerializer,
parameters=[
OpenApiParameter(
'X-Internal-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=True,
description='کلید مخفیِ فراخوانی داخلی/سرور-به-سرور (settings.INTERNAL_SERVICE_KEY).',
),
OpenApiParameter(
'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False,
description=(
'کلید یکتای idempotency برای جلوگیری از ساخت سفارش تکراری هنگام retry. '
'در صورت عدم ارسال، یک کلید تصادفی ساخته می‌شود.'
),
),
],
tags=['Payment (payment)'],
)
def post(self, request):
serializer = CreateOrderRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
order, payment_url = create_order(
user_id=int(v['userId']),
amount=v['amount'],
currency=v.get('currency') or 'IRT',
subscription_id=int(v['subscriptionId']) if v.get('subscriptionId') else None,
idempotency_key=request.headers.get('Idempotency-Key'),
)
data = {'orderId': str(order.id), 'status': order.status, 'paymentUrl': payment_url}
return Response(CreateOrderResponseSerializer(data).data)
class PaymentWebhookView(APIView):
"""
POST /api/payment/webhook — کال‌بک درگاه پرداخت (از سمت درگاه میاد، نه کلاینت)
محافظت‌شده با HasWebhookSecret (هدر X-Webhook-Secret) تا هر کلاینتی
نتونه با یک orderId معتبر و status=paid، بدون پرداخت واقعی، اشتراک
فعال کنه. همچنین idempotent شده: اگه سفارش از قبل paid باشه، دوباره
Transaction/notify تکراری نمی‌سازه (مثلاً وقتی درگاه callback رو دوباره
می‌فرسته).
"""
permission_classes = [HasWebhookSecret]
# ⚠️ افزوده شده: مستندسازی هدر X-Webhook-Secret که قبلاً در Swagger UI
# قابل تنظیم/تست نبود.
@extend_schema(
request=WebhookRequestSerializer,
responses=WebhookResponseSerializer,
parameters=[
OpenApiParameter(
'X-Webhook-Secret', OpenApiTypes.STR, OpenApiParameter.HEADER, required=True,
description='کلید مخفیِ وب‌هوک درگاه پرداخت (settings.PAYMENT_WEBHOOK_SECRET).',
),
],
tags=['Payment (payment)'],
)
def post(self, request):
serializer = WebhookRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
gateway_ref_id = v.get('gatewayRefId', '')
with transaction.atomic():
order = get_object_or_404(Order.objects.select_for_update(), pk=v['orderId'])
already_paid = order.status == 'paid'
# ⚠️ اصلاح امنیتی/صحت داده: قبلاً هر بار فراخوانی webhook (حتی
# اگه درگاه به‌خاطر عدم دریافت ۲۰۰ یا هر دلیل دیگه‌ای همون
# کال‌بک رو دوباره می‌فرستاد) یک رکورد Transaction جدید می‌ساخت.
# حالا اگه رکوردی با همین order/status/gatewayRefId از قبل
# موجود باشه، تکراری در نظر گرفته می‌شه و نه Transaction جدید
# ساخته می‌شه و نه اثرات جانبی (فعال‌سازی اشتراک/notify) دوباره
# اجرا می‌شن.
is_duplicate = Transaction.objects.filter(
order=order, status=v['status'], gateway_ref_id=gateway_ref_id,
).exists()
if not is_duplicate:
order.status = v['status']
order.save(update_fields=['status'])
Transaction.objects.create(
order=order,
gateway=v.get('gateway') or 'zarinpal',
gateway_ref_id=gateway_ref_id,
status=v['status'],
paid_at=timezone.now() if v['status'] == 'paid' else None,
)
if v['status'] == 'paid' and not already_paid and not is_duplicate:
if order.subscription_id:
# فعال‌سازی اشتراک بعد از پرداخت موفق. ارتباط با اپ
# subscription هم مثل create_order یک import مستقیم به
# تابع سرویسه (رفرنس سبک با id، نه FK واقعی و نه HTTP
# call به خودمون).
from subscription.services import activate_subscription
activate_subscription(order.subscription_id)
# رویداد order_paid طبق سند notifications باید ساطع بشه؛
# قبلاً این فراخوانی جا افتاده بود (فقط اشتراک فعال می‌شد).
from notifications.services import notify
notify(
user_id=order.user_id,
type='order_paid',
title='پرداخت با موفقیت انجام شد',
related_id=order.id,
)
return Response(WebhookResponseSerializer({'received': True}).data)
class OrderDetailView(APIView):
"""GET /api/payment/orders/{id} — وضعیت سفارش"""
permission_classes = [IsAuthenticated]
@extend_schema(responses=OrderDetailResponseSerializer, tags=['Payment (payment)'])
def get(self, request, order_id):
order = get_object_or_404(Order, pk=order_id, user_id=request.user.id)
data = {
'orderId': str(order.id),
'status': order.status,
'amount': order.amount,
'transactions': [
{
'id': str(t.id),
'gateway': t.gateway,
'status': t.status,
'paidAt': t.paid_at,
}
for t in order.transactions.all()
],
}
return Response(OrderDetailResponseSerializer(data).data)

0
progress/__init__.py Normal file
View File

30
progress/admin.py Normal file
View File

@@ -0,0 +1,30 @@
from django.contrib import admin
from .models import ActiveDay, LeaderboardEntry, StreakRecord, UserHearts, UserStats
@admin.register(UserStats)
class UserStatsAdmin(admin.ModelAdmin):
list_display = ('id', 'user_id', 'xp', 'coins', 'progress_percent')
@admin.register(LeaderboardEntry)
class LeaderboardEntryAdmin(admin.ModelAdmin):
list_display = ('id', 'user_id', 'period', 'xp_in_period', 'rank')
list_filter = ('period',)
class ActiveDayInline(admin.TabularInline):
model = ActiveDay
extra = 1
@admin.register(StreakRecord)
class StreakRecordAdmin(admin.ModelAdmin):
list_display = ('id', 'user_id', 'current_streak', 'longest_streak')
inlines = [ActiveDayInline]
@admin.register(UserHearts)
class UserHeartsAdmin(admin.ModelAdmin):
list_display = ('id', 'user_id', 'current_hearts', 'max_hearts', 'next_refill_at')

6
progress/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ProgressConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'progress'

View File

@@ -0,0 +1,68 @@
# 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='LeaderboardEntry',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(db_index=True)),
('period', models.CharField(choices=[('weekly', 'هفتگی'), ('all_time', 'کل زمان')], max_length=10)),
('xp_in_period', models.PositiveIntegerField(default=0)),
('rank', models.PositiveIntegerField()),
],
options={
'ordering': ['rank'],
},
),
migrations.CreateModel(
name='StreakRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(db_index=True, unique=True)),
('current_streak', models.PositiveIntegerField(default=0)),
('longest_streak', models.PositiveIntegerField(default=0)),
],
),
migrations.CreateModel(
name='UserHearts',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(db_index=True, unique=True)),
('current_hearts', models.PositiveSmallIntegerField(default=5)),
('max_hearts', models.PositiveSmallIntegerField(default=5)),
('next_refill_at', models.DateTimeField(blank=True, null=True)),
],
),
migrations.CreateModel(
name='UserStats',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(db_index=True, unique=True)),
('xp', models.PositiveIntegerField(default=0)),
('coins', models.PositiveIntegerField(default=0)),
('progress_percent', models.FloatField(default=0)),
],
),
migrations.CreateModel(
name='ActiveDay',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('streak', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='active_days', to='progress.streakrecord')),
],
options={
'ordering': ['date'],
},
),
]

View File

56
progress/models.py Normal file
View File

@@ -0,0 +1,56 @@
from django.db import models
class UserStats(models.Model):
user_id = models.IntegerField(unique=True, db_index=True) # ارجاع سبک به accounts.User
xp = models.PositiveIntegerField(default=0)
coins = models.PositiveIntegerField(default=0)
progress_percent = models.FloatField(default=0)
def __str__(self):
return f"stats-user{self.user_id}"
class LeaderboardEntry(models.Model):
PERIOD_CHOICES = [('weekly', 'هفتگی'), ('all_time', 'کل زمان')]
user_id = models.IntegerField(db_index=True)
period = models.CharField(max_length=10, choices=PERIOD_CHOICES)
xp_in_period = models.PositiveIntegerField(default=0)
rank = models.PositiveIntegerField()
class Meta:
ordering = ['rank']
def __str__(self):
return f"lb-{self.period}-user{self.user_id}-rank{self.rank}"
class StreakRecord(models.Model):
user_id = models.IntegerField(unique=True, db_index=True)
current_streak = models.PositiveIntegerField(default=0)
longest_streak = models.PositiveIntegerField(default=0)
def __str__(self):
return f"streak-user{self.user_id}"
class ActiveDay(models.Model):
streak = models.ForeignKey(StreakRecord, on_delete=models.CASCADE, related_name='active_days')
date = models.DateField()
class Meta:
ordering = ['date']
def __str__(self):
return str(self.date)
class UserHearts(models.Model):
user_id = models.IntegerField(unique=True, db_index=True)
current_hearts = models.PositiveSmallIntegerField(default=5)
max_hearts = models.PositiveSmallIntegerField(default=5)
next_refill_at = models.DateTimeField(null=True, blank=True)
def __str__(self):
return f"hearts-user{self.user_id}"

Some files were not shown because too many files have changed in this diff Show More