Initial commit - clean project structure

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

6
.gitignore vendored Normal file
View File

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

63
README.md Normal file
View File

@@ -0,0 +1,63 @@
# Tadrisino API
بک‌اند اپلیکیشن آموزشی Tadrisino — Django + Django REST Framework.
احراز هویت با OTP، محتوای درسی و پیشرفت کاربر، دستیار هوش مصنوعی، اشتراک و پرداخت،
تخفیف، اعلان‌ها، همکاری در فروش، پشتیبانی و حساب خانواده.
## اجرا
```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
```
## مستندات API
| | |
|---|---|
| Swagger UI | `http://127.0.0.1:8000/api/docs/` |
| Redoc | `http://127.0.0.1:8000/api/redoc/` |
| پنل ادمین | `http://127.0.0.1:8000/admin/` |
**شروع سریع در Swagger:**
1. `POST /api/auth/session` با `{"type":"guest","deviceId":"d_test1"}` → یک `token` بگیرید.
2. روی دکمه‌ی **Authorize** بزنید و فقط خودِ `token` رو (بدون کلمه‌ی Bearer) وارد کنید.
3. برای تست لاگین با شماره: `POST /api/auth/otp/request` بزنید، یا در محیط توسعه کد **0000** رو با هر شماره‌ای در `/api/auth/login` امتحان کنید.
## ساختار پروژه
| اپ | مسئولیت | مدل‌های اصلی |
|---|---|---|
| `accounts` | هویت کاربر و session | `User`, `Profile`, `Session`, `OTPCode` |
| `content` | محتوای درسی و پیشرفت روی درس | `Grade`, `Unit`, `Node`, `Lesson`, `Question`, `Choice`, `NodeProgress` |
| `progress` | آمار، لیدربرد، streak | `UserStats`, `LeaderboardEntry`, `StreakRecord`, `UserHearts` |
| `ai_tutor` | دستیار هوش مصنوعی | `Conversation`, `Message`, `MessageFeedback` |
| `subscription` | پلن و اشتراک | `Plan`, `Subscription` |
| `payment` | سفارش و تراکنش | `Order`, `Transaction` |
| `discount` | کد تخفیف و آفر | `DiscountCode`, `Redemption`, `Offer`, `UserOffer` |
| `notifications` | اعلان‌ها | `Notification` |
| `affiliate` | همکاری در فروش | `AffiliateAccount`, `Conversion` |
| `support` | تیکت و گزارش باگ | `BugReport`, `Ticket`, `TicketMessage` |
| `family` | حساب خانواده / کنترل والدین | `FamilyLink`, `ParentalSettings`, `WeeklyReportLog` |
| `statuslog` | لاگ عمومی تغییر status، مستقل از بقیه‌ی اپ‌ها | `StatusChangeLog` |
| `config` | تنظیمات و url های اصلی پروژه | — |
## نکات فنی مهم
- **احراز هویت**: Bearer token امضاشده و stateless (`django.core.signing`، بدون کتابخانه‌ی JWT خارجی)، هماهنگ با مدل `Session`.
- **`accounts.User`** عمداً `AUTH_USER_MODEL` جنگو نیست؛ یک مدل بیزنسی مستقل است.
- بین اپ‌ها به‌جای FK واقعی از ارجاع سبک (`user_id`, `object_id` + `label`) استفاده شده تا هر اپ در آینده قابل جدا شدن باشد.
- `POST /api/ai/ask` فعلاً پاسخ stub برمی‌گرداند؛ محل اتصال مدل واقعی در `ai_tutor/views.py` (تابع `generate_tutor_answer`) مشخص شده.
- `DEV_MASTER_OTP` (کد `0000`) فقط برای تست در توسعه است — **پیش از دیپلوی پروداکشن باید حذف یا به `DEBUG` مقیّد شود.**
## قبل از دیپلوی پروداکشن
- [ ] `DEBUG = False` و `SECRET_KEY` از env
- [ ] حذف/مقیّدسازی `DEV_MASTER_OTP` به محیط توسعه
- [ ] rate limit روی `auth/otp/request` و `auth/login`
- [ ] دیتابیس Postgres به‌جای SQLite

0
accounts/__init__.py Normal file
View File

27
accounts/admin.py Normal file
View File

@@ -0,0 +1,27 @@
from django.contrib import admin
from .models import OTPCode, Profile, Session, User
@admin.register(User)
class UserAdmin(admin.ModelAdmin):
list_display = ('id', 'name', 'phone', 'device_id', 'is_guest', 'grade_id', 'created_at')
search_fields = ('name', 'phone', 'device_id')
list_filter = ('is_guest',)
@admin.register(Profile)
class ProfileAdmin(admin.ModelAdmin):
list_display = ('id', 'user', 'birth_year', 'parent_phone')
@admin.register(Session)
class SessionAdmin(admin.ModelAdmin):
list_display = ('id', 'user', 'device_id', 'expires_at', 'revoked')
list_filter = ('revoked',)
@admin.register(OTPCode)
class OTPCodeAdmin(admin.ModelAdmin):
list_display = ('id', 'phone', 'code', 'expires_at', 'is_used')
list_filter = ('is_used',)

10
accounts/apps.py Normal file
View File

@@ -0,0 +1,10 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'accounts'
def ready(self):
# ثبت OpenApiAuthenticationExtension برای دکمه‌ی Authorize توی Swagger UI
from . import schema # noqa: F401

View File

@@ -0,0 +1,60 @@
from django.conf import settings
from django.core import signing
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
from .models import User
ACCESS_TOKEN_SALT = 'tadrisino-access-token'
def issue_access_token(user_id: int) -> str:
"""
ساخت access token امضاشده (stateless) — دیتابیس ذخیره نمی‌شه، فقط
امضا و expiry داره. با django.core.signing (بدون نیاز به کتابخونه‌ی JWT).
"""
signer = signing.TimestampSigner(salt=ACCESS_TOKEN_SALT)
return signer.sign(str(user_id))
def decode_access_token(token: str) -> int:
signer = signing.TimestampSigner(salt=ACCESS_TOKEN_SALT)
try:
value = signer.unsign(token, max_age=settings.ACCESS_TOKEN_TTL_SECONDS)
except signing.SignatureExpired:
raise AuthenticationFailed('توکن منقضی شده است.')
except signing.BadSignature:
raise AuthenticationFailed('توکن نامعتبر است.')
return int(value)
class TadrisinoTokenAuthentication(BaseAuthentication):
"""
هدر مورد انتظار: Authorization: Bearer <access_token>
اگه هدر نباشه، درخواست به‌صورت anonymous ادامه پیدا می‌کنه (None, None)
و View خودش تصمیم می‌گیره که آیا auth اجباریه (IsAuthenticated) یا نه.
"""
keyword = 'Bearer'
def authenticate(self, request):
auth_header = request.headers.get('Authorization', '')
if not auth_header:
return None
parts = auth_header.split()
if len(parts) != 2 or parts[0] != self.keyword:
return None
token = parts[1]
user_id = decode_access_token(token)
try:
user = User.objects.get(pk=user_id)
except User.DoesNotExist:
raise AuthenticationFailed('کاربر پیدا نشد.')
return (user, token)
def authenticate_header(self, request):
return self.keyword

View File

@@ -0,0 +1,62 @@
# Generated by Django 5.2.17 on 2026-08-10 11:37
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='OTPCode',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('phone', models.CharField(db_index=True, max_length=15)),
('code', models.CharField(max_length=6)),
('expires_at', models.DateTimeField()),
('is_used', models.BooleanField(default=False)),
],
),
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('phone', models.CharField(blank=True, db_index=True, max_length=15, null=True, unique=True)),
('device_id', models.CharField(blank=True, db_index=True, max_length=64, null=True)),
('name', models.CharField(blank=True, max_length=100)),
('avatar', models.CharField(blank=True, max_length=100)),
('is_guest', models.BooleanField(default=True)),
('grade_id', models.IntegerField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Session',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('refresh_token', models.CharField(max_length=200, unique=True)),
('device_id', models.CharField(blank=True, max_length=64)),
('expires_at', models.DateTimeField()),
('revoked', models.BooleanField(default=False)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='accounts.user')),
],
),
migrations.CreateModel(
name='Profile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('birth_year', models.PositiveSmallIntegerField(blank=True, null=True)),
('parent_phone', models.CharField(blank=True, max_length=15)),
('bio', models.CharField(blank=True, max_length=200)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to='accounts.user')),
],
),
]

View File

67
accounts/models.py Normal file
View File

@@ -0,0 +1,67 @@
from django.db import models
class User(models.Model):
"""
کاربر اصلی سیستم (مهمان یا ثبت‌نام‌شده با شماره).
توجه: این مدل، AUTH_USER_MODEL جنگو نیست؛ یک مدل بیزنسی مستقله که
با TadrisinoTokenAuthentication سفارشی احراز هویت می‌شه.
grade_id فقط یک ارجاع سبک (بدون FK واقعی) به content.Grade هست؛
این اپ عمداً هیچ‌وقت FK واقعی به مدل اپ دیگه نمی‌زنه.
"""
phone = models.CharField(max_length=15, unique=True, null=True, blank=True, db_index=True)
device_id = models.CharField(max_length=64, null=True, blank=True, db_index=True)
name = models.CharField(max_length=100, blank=True)
avatar = models.CharField(max_length=100, blank=True)
is_guest = models.BooleanField(default=True)
grade_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Grade
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return self.name or self.phone or f"guest-{self.pk}"
@property
def is_authenticated(self):
# این مدل AUTH_USER_MODEL نیست؛ این property فقط برای سازگاری با
# rest_framework.permissions.IsAuthenticated لازمه.
return True
class Profile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
birth_year = models.PositiveSmallIntegerField(null=True, blank=True)
parent_phone = models.CharField(max_length=15, blank=True)
bio = models.CharField(max_length=200, blank=True)
def __str__(self):
return f"profile-{self.user_id}"
class Session(models.Model):
"""
نگهداری refresh_token هر ورود. توکن دسترسی (access token) در دیتابیس
ذخیره نمی‌شه؛ به‌صورت stateless امضا (signed) می‌شه — دقیقاً طبق فیلدهای
همین مدل که فقط refresh_token داره، نه access token.
"""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sessions')
refresh_token = models.CharField(max_length=200, unique=True)
device_id = models.CharField(max_length=64, blank=True)
expires_at = models.DateTimeField()
revoked = models.BooleanField(default=False)
def __str__(self):
return f"session-{self.pk}-user{self.user_id}"
class OTPCode(models.Model):
phone = models.CharField(max_length=15, db_index=True)
code = models.CharField(max_length=6)
expires_at = models.DateTimeField()
is_used = models.BooleanField(default=False)
def __str__(self):
return f"otp-{self.phone}"

22
accounts/schema.py Normal file
View File

@@ -0,0 +1,22 @@
from drf_spectacular.extensions import OpenApiAuthenticationExtension
class TadrisinoTokenAuthenticationScheme(OpenApiAuthenticationExtension):
"""
این کلاس به drf-spectacular می‌گه که TadrisinoTokenAuthentication یک
Bearer token هست، تا دکمه‌ی "Authorize" توی Swagger UI درست نمایش داده بشه.
"""
target_class = 'accounts.authentication.TadrisinoTokenAuthentication'
name = 'BearerAuth'
def get_security_definition(self, auto_schema):
return {
'type': 'http',
'scheme': 'bearer',
'bearerFormat': 'access_token',
'description': (
'توکن دسترسی که از /api/auth/session یا /api/auth/login گرفته می‌شه. '
'در فیلد Value فقط خودِ توکن رو بذارید (بدون کلمه‌ی Bearer)؛ Swagger '
'خودش اضافه می‌کنه.'
),
}

68
accounts/serializers.py Normal file
View File

@@ -0,0 +1,68 @@
from rest_framework import serializers
# ---- Request serializers ----
class GuestSessionRequestSerializer(serializers.Serializer):
type = serializers.ChoiceField(choices=['guest'])
deviceId = serializers.CharField(max_length=64)
class LoginRequestSerializer(serializers.Serializer):
phone = serializers.CharField(max_length=15)
otp = serializers.CharField(max_length=6)
class RefreshRequestSerializer(serializers.Serializer):
refreshToken = serializers.CharField()
class SetGradeRequestSerializer(serializers.Serializer):
gradeId = serializers.IntegerField()
class RequestOTPRequestSerializer(serializers.Serializer):
"""اپ کمکی برای گرفتن OTP (در سند اصلی نبود، برای تست‌پذیری Swagger اضافه شد)."""
phone = serializers.CharField(max_length=15)
# ---- Response serializers ----
class GuestSessionResponseSerializer(serializers.Serializer):
userId = serializers.CharField()
token = serializers.CharField()
isNewUser = serializers.BooleanField()
isGuest = serializers.BooleanField()
class LoginResponseSerializer(serializers.Serializer):
userId = serializers.CharField()
token = serializers.CharField()
refreshToken = serializers.CharField()
class RefreshResponseSerializer(serializers.Serializer):
token = serializers.CharField()
expiresIn = serializers.IntegerField()
class GradeSerializer(serializers.Serializer):
id = serializers.IntegerField()
title = serializers.CharField()
icon = serializers.CharField()
color = serializers.CharField()
class GradesListResponseSerializer(serializers.Serializer):
grades = GradeSerializer(many=True)
class SetGradeResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()
gradeId = serializers.IntegerField()
class RequestOTPResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()
devOtp = serializers.CharField()
note = serializers.CharField()

11
accounts/signals.py Normal file
View File

@@ -0,0 +1,11 @@
"""
سیگنال ثبت‌نامِ واقعی کاربر (نه guest session). سایر اپ‌ها (مثلاً discount
برای اختصاص خودکار آفر) به‌جای import کردن مستقیم از accounts، فقط این
سیگنال رو در apps.py خودشون connect می‌کنن؛ این یعنی accounts هیچ‌وقت لازم
نیست بدونه discount/offer اصلاً وجود داره — دقیقاً همون درخواست سینیور برای
signal-based بودن منطق اعمال آفر.
"""
from django.dispatch import Signal
# kwargs: user_id (int)
user_registered = Signal()

3
accounts/tests.py Normal file
View File

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

17
accounts/urls.py Normal file
View File

@@ -0,0 +1,17 @@
from django.urls import path
from .views import (
GuestSessionView,
LoginView,
RefreshTokenView,
RequestOTPView,
SetUserGradeView,
)
urlpatterns = [
path('auth/session', GuestSessionView.as_view(), name='auth-session'),
path('auth/otp/request', RequestOTPView.as_view(), name='auth-otp-request'),
path('auth/login', LoginView.as_view(), name='auth-login'),
path('auth/refresh', RefreshTokenView.as_view(), name='auth-refresh'),
path('user/grade', SetUserGradeView.as_view(), name='user-grade'),
]

194
accounts/views.py Normal file
View File

@@ -0,0 +1,194 @@
import random
import uuid
from django.conf import settings
from django.utils import timezone
from drf_spectacular.utils import extend_schema
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from .authentication import issue_access_token
from .models import OTPCode, Session, User
from .serializers import (
GuestSessionRequestSerializer,
GuestSessionResponseSerializer,
LoginRequestSerializer,
LoginResponseSerializer,
RefreshRequestSerializer,
RefreshResponseSerializer,
RequestOTPRequestSerializer,
RequestOTPResponseSerializer,
SetGradeRequestSerializer,
SetGradeResponseSerializer,
)
class GuestSessionView(APIView):
"""POST /api/auth/session — ایجاد سشن مهمان"""
@extend_schema(
request=GuestSessionRequestSerializer,
responses=GuestSessionResponseSerializer,
tags=['Identity & Profile (accounts)'],
)
def post(self, request):
serializer = GuestSessionRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
device_id = serializer.validated_data['deviceId']
user, is_new_user = User.objects.get_or_create(
device_id=device_id,
is_guest=True,
defaults={'name': ''},
)
token = issue_access_token(user.id)
data = {
'userId': str(user.id),
'token': token,
'isNewUser': is_new_user,
'isGuest': True,
}
return Response(GuestSessionResponseSerializer(data).data)
class RequestOTPView(APIView):
"""
POST /api/auth/otp/request — درخواست کد OTP (کمکی، برای تست از طریق Swagger)
در محیط توسعه، کد ساخته‌شده در پاسخ برگردونده می‌شه تا نیازی به SMS واقعی نباشه.
"""
@extend_schema(
request=RequestOTPRequestSerializer,
responses=RequestOTPResponseSerializer,
tags=['Identity & Profile (accounts)'],
)
def post(self, request):
serializer = RequestOTPRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
phone = serializer.validated_data['phone']
code = f'{random.randint(0, 9999):04d}'
OTPCode.objects.create(
phone=phone,
code=code,
expires_at=timezone.now() + timezone.timedelta(minutes=5),
)
data = {
'success': True,
'devOtp': code,
'note': f'در پروداکشن این کد پیامک می‌شه؛ برای تست همیشه {settings.DEV_MASTER_OTP} هم قبول می‌شه.',
}
return Response(RequestOTPResponseSerializer(data).data)
class LoginView(APIView):
"""POST /api/auth/login — ورود با OTP"""
@extend_schema(
request=LoginRequestSerializer,
responses=LoginResponseSerializer,
tags=['Identity & Profile (accounts)'],
)
def post(self, request):
serializer = LoginRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
phone = serializer.validated_data['phone']
otp = serializer.validated_data['otp']
valid = False
if otp == settings.DEV_MASTER_OTP:
valid = True
else:
otp_qs = OTPCode.objects.filter(
phone=phone, code=otp, is_used=False, expires_at__gte=timezone.now()
).order_by('-id')
otp_obj = otp_qs.first()
if otp_obj:
otp_obj.is_used = True
otp_obj.save(update_fields=['is_used'])
valid = True
if not valid:
return Response({'detail': 'کد OTP نامعتبر یا منقضی‌شده است.'}, status=status.HTTP_400_BAD_REQUEST)
user, created = User.objects.get_or_create(
phone=phone,
defaults={'is_guest': False, 'name': ''},
)
if user.is_guest:
user.is_guest = False
user.save(update_fields=['is_guest'])
if created:
# ثبت‌نام واقعی (نه guest session): آفرهای auto_assign_on_signup
# (مثل یک هفته رایگان) از طریق discount/receivers.py روی همین
# سیگنال اعمال می‌شن؛ این ویو از وجود آفر/discount چیزی نمی‌دونه.
from .signals import user_registered
user_registered.send(sender=User, user_id=user.id)
refresh_token = uuid.uuid4().hex
Session.objects.create(
user=user,
refresh_token=refresh_token,
device_id=request.data.get('deviceId', ''),
expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
)
access_token = issue_access_token(user.id)
data = {
'userId': str(user.id),
'token': access_token,
'refreshToken': refresh_token,
}
return Response(LoginResponseSerializer(data).data)
class RefreshTokenView(APIView):
"""POST /api/auth/refresh — تمدید توکن"""
@extend_schema(
request=RefreshRequestSerializer,
responses=RefreshResponseSerializer,
tags=['Identity & Profile (accounts)'],
)
def post(self, request):
serializer = RefreshRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
refresh_token = serializer.validated_data['refreshToken']
session = Session.objects.filter(
refresh_token=refresh_token, revoked=False, expires_at__gte=timezone.now()
).first()
if not session:
return Response({'detail': 'رفرش‌توکن نامعتبر یا منقضی‌شده است.'}, status=status.HTTP_401_UNAUTHORIZED)
access_token = issue_access_token(session.user_id)
data = {'token': access_token, 'expiresIn': settings.ACCESS_TOKEN_TTL_SECONDS}
return Response(RefreshResponseSerializer(data).data)
class SetUserGradeView(APIView):
"""POST /api/user/grade — ثبت پایه‌ی کاربر (نیاز به احراز هویت)"""
permission_classes = [IsAuthenticated]
@extend_schema(
request=SetGradeRequestSerializer,
responses=SetGradeResponseSerializer,
tags=['Identity & Profile (accounts)'],
)
def post(self, request):
serializer = SetGradeRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
grade_id = serializer.validated_data['gradeId']
user = request.user
user.grade_id = grade_id
user.save(update_fields=['grade_id'])
data = {'success': True, 'gradeId': grade_id}
return Response(SetGradeResponseSerializer(data).data)

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

253
config/settings.py Normal file
View File

@@ -0,0 +1,253 @@
"""
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',
# Tadrisino apps (سیستم پرداخت بازار + آفر/تخفیف + لاگ عمومی تغییر Status)
'statuslog',
]
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',
'DESCRIPTION': (
'بک‌اند اپلیکیشن آموزشی Tadrisino — احراز هویت با OTP، محتوای درسی '
'و پیشرفت کاربر، دستیار هوش مصنوعی، اشتراک و پرداخت، تخفیف، '
'اعلان‌ها، همکاری در فروش، پشتیبانی و حساب خانواده.'
),
'VERSION': '1.0.0',
'SERVE_INCLUDE_SCHEMA': False,
'COMPONENT_SPLIT_REQUEST': True,
'SWAGGER_UI_SETTINGS': {
'deepLinking': True,
'displayRequestDuration': True,
'defaultModelsExpandDepth': -1,
'docExpansion': 'list',
'filter': True,
'persistAuthorization': True,
},
}
# اعتبار توکن دسترسی (ثانیه) و رفرش‌توکن (روز)
ACCESS_TOKEN_TTL_SECONDS = 3600
REFRESH_TOKEN_TTL_DAYS = 30
# در محیط توسعه/تست، این کد OTP همیشه معتبره تا بدون نیاز به SMS واقعی
# بشه لاگین رو با Swagger تست کرد. در پروداکشن حتماً حذف بشه.
DEV_MASTER_OTP = '0000'
# -----------------------------------------------------------------------
# Content progress (NodeProgress status)
# -----------------------------------------------------------------------
# بعد از چند روز بدون فعالیت روی یک Node که status اون in_progress هست،
# management command مربوطه (content/management/commands/
# mark_abandoned_node_progress.py) اونو abandoned علامت می‌زنه. ۱۴ روز
# (نه ۳-۷ روزِ رایج در اپ‌های مشابه) عمداً انتخاب شده چون قطعی/محدودیت
# اینترنت و دیتای موبایل در ایران باعث وقفه‌های چندروزه‌ی غیرارادی
# می‌شه که نباید فوراً به‌عنوان «رهاکردن درس» حساب بشه.
NODE_PROGRESS_ABANDON_DAYS = 14
# -----------------------------------------------------------------------
# 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')
# -----------------------------------------------------------------------
# Bazaar In-App Billing (پرداخت درون‌برنامه‌ای کافه‌بازار — REST API v2)
# -----------------------------------------------------------------------
# طبق داکیومنت رسمی (developers.cafebazaar.ir/fa/guidelines/in-app-billing)،
# صحت‌سنجی پرداخت بازار یک OAuth2 سرور-به-سرور‌ه: یک بار با CLIENT_ID/
# CLIENT_SECRET و کد بازگشتی از پنل devapi، REFRESH_TOKEN گرفته می‌شه (یک‌بار،
# دستی)؛ از اون به بعد بک‌اند با همین REFRESH_TOKEN، ACCESS_TOKEN تازه
# می‌گیره و با اون به endpoint وضعیت خرید/اشتراک درخواست می‌زنه.
# ⚠️ کافه‌بازار اواخر ۱۴۰۳ روش صحت‌سنجی رو تغییر داد؛ قبل از اتصال واقعی،
# حتماً آخرین مسیر endpointها رو از همون صفحه‌ی رسمی/پنل devapi خودتون
# تایید کنید (BAZAAR_API_BASE پایینی رو بر همون اساس ست کنید).
BAZAAR_PACKAGE_NAME = os.environ.get('BAZAAR_PACKAGE_NAME', 'ir.tadrisino.app')
BAZAAR_CLIENT_ID = os.environ.get('BAZAAR_CLIENT_ID', '')
BAZAAR_CLIENT_SECRET = os.environ.get('BAZAAR_CLIENT_SECRET', '')
BAZAAR_REFRESH_TOKEN = os.environ.get('BAZAAR_REFRESH_TOKEN', '')
BAZAAR_API_BASE = os.environ.get('BAZAAR_API_BASE', 'https://pardakht.cafebazaar.ir/devapi/v2')
# 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

62
content/admin.py Normal file
View File

@@ -0,0 +1,62 @@
from django.contrib import admin
from .models import (
Choice, Grade, Lesson, Node, NodeProgress, 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',)
@admin.register(NodeProgress)
class NodeProgressAdmin(admin.ModelAdmin):
list_display = (
'id', 'user_id', 'node', 'status', 'lessons_completed_count',
'started_at', 'last_activity_at', 'completed_at',
)
list_filter = ('status', 'node__unit')
search_fields = ('user_id',)

9
content/apps.py Normal file
View File

@@ -0,0 +1,9 @@
from django.apps import AppConfig
class ContentConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'content'
def ready(self):
from . import receivers # noqa: F401

View File

View File

View File

@@ -0,0 +1,44 @@
"""
تشخیصِ «نصفه‌رها» (abandoned) با سیگنال قابل انجام نیست چون به گذر زمانِ
بدون هیچ رویدادی نیاز داره، نه یک event مشخص؛ برای همین management
commandه، نه receiver. باید periodic اجرا بشه (cron / celery beat)،
مثلاً یک‌بار در روز:
python manage.py mark_abandoned_node_progress
آستانه از settings.NODE_PROGRESS_ABANDON_DAYS خونده می‌شه (پیش‌فرض ۱۴ روز).
"""
from django.conf import settings
from django.core.management.base import BaseCommand
from django.utils import timezone
from content.models import NodeProgress
from statuslog.signals import status_changed
class Command(BaseCommand):
help = 'رکوردهای NodeProgress در حال انجام که مدت‌ها فعالیتی نداشته‌اند را نصفه‌رها (abandoned) علامت می‌زند.'
def handle(self, *args, **options):
abandon_days = getattr(settings, 'NODE_PROGRESS_ABANDON_DAYS', 14)
threshold = timezone.now() - timezone.timedelta(days=abandon_days)
stale_qs = NodeProgress.objects.filter(
status='in_progress', last_activity_at__lt=threshold,
)
count = 0
for progress in stale_qs:
old_status = progress.status
progress.status = 'abandoned'
progress.save(update_fields=['status'])
status_changed.send(
sender='content.NodeProgress', object_id=progress.id,
old_status=old_status, new_status='abandoned',
event='inactivity_timeout', user_id=progress.user_id,
)
count += 1
self.stdout.write(self.style.SUCCESS(
f'{count} رکورد NodeProgress به abandoned تغییر کرد (آستانه: {abandon_days} روز).'
))

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

@@ -0,0 +1,31 @@
# Generated by Django 5.2.17 on 2026-08-18 03:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('content', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='NodeProgress',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(db_index=True)),
('status', models.CharField(choices=[('not_started', 'شروع\u200cنشده'), ('in_progress', 'در حال انجام'), ('completed', 'تکمیل\u200cشده'), ('abandoned', 'نصفه\u200cرها')], default='not_started', max_length=15)),
('lessons_completed_count', models.PositiveIntegerField(default=0)),
('started_at', models.DateTimeField(blank=True, null=True)),
('completed_at', models.DateTimeField(blank=True, null=True)),
('last_activity_at', models.DateTimeField(blank=True, db_index=True, null=True)),
('node', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='progress_records', to='content.node')),
],
options={
'indexes': [models.Index(fields=['status', 'last_activity_at'], name='content_nod_status_7971c6_idx')],
'constraints': [models.UniqueConstraint(fields=('user_id', 'node'), name='unique_node_progress_per_user')],
},
),
]

View File

164
content/models.py Normal file
View File

@@ -0,0 +1,164 @@
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 NodeProgress(models.Model):
"""
وضعیتِ پیشرفتِ هر کاربر روی هر Node (نه Lesson و نه Unit).
چرا سطح Node و نه Lesson/Unit:
- Lesson خیلی ریزه: هر Lesson خودش با UserLessonCompletion دقیقاً
«انجام‌شده / نشده» ثبت می‌شه؛ مفهوم «نصفه‌رها» برای یک درسِ چند
دقیقه‌ای معنی نداره (یا تمومش کرده یا نه).
- Unit خیلی کلیه (چند Node/چند هفته درس)؛ اگه status روی این سطح
باشه، کاربری که یک هفته از ۳ هفته‌ی یک Unit رو غیرفعال بوده هم
«نصفه‌رها» می‌شه که گمراه‌کننده‌ست.
- Node (چند Lesson مرتبط، معادل «مبحث»/skill) دقیقاً سطحیه که
اپ‌های مشابه (Duolingo: پیشرفتِ هر skill/unit روی نقشه‌ی مهارت،
Khan Academy: mastery روی هر topic/skill) روش status نشون
می‌دن؛ برای اپِ ریاضیِ ابتدایی هم واحدِ معنادار «مبحث» همینه.
استاتوس‌ها (طبق درخواست: تکمیل‌شده / نصفه‌رها / در حال انجام؛ + یک
حالت پایه‌ی چهارم که در اپ‌های مشابه هم هست):
- not_started: هنوز هیچ فعالیتی (نه پاسخ به سوال، نه تکمیل درس)
روی هیچ‌کدوم از Lessonهای این Node نداشته. (معادل 'locked'/
'unlocked بدون فعالیت' در نقشه‌ی مهارتِ HomeView؛ اون یکی برای
UI نمایش نقشه‌ست، این یکی برای ردیابی/گزارش‌گیریه — عمداً جدا
نگه داشته شده تا منطق قفل/باز نقشه به این وابسته نشه.)
- in_progress: حداقل یک فعالیت داشته ولی هنوز همه‌ی Lessonهای
Node تکمیل نشدن.
- completed: همه‌ی Lessonهای Node تکمیل شدن.
- abandoned («نصفه‌رها»): در حال انجام بوده ولی به مدت
NODE_PROGRESS_ABANDON_DAYS (پیش‌فرض ۱۴ روز — بلندتر از حالت
معمولِ اپ‌های مشابه چون با توجه به شرایط اینترنت/دیتای موبایل در
ایران، وقفه‌ی چند روزه‌ی کاربر لزوماً به معنی رهاکردنِ درس نیست؛
تشخیصش با management command زمان‌بندی‌شده، نه سیگنال، چون به
گذر زمانِ بدون رویداد نیاز داره) فعالیتی نداشته. اگه دوباره
فعالیت کنه، برمی‌گرده به in_progress.
"""
STATUS_CHOICES = [
('not_started', 'شروع‌نشده'),
('in_progress', 'در حال انجام'),
('completed', 'تکمیل‌شده'),
('abandoned', 'نصفه‌رها'),
]
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
node = models.ForeignKey(Node, on_delete=models.CASCADE, related_name='progress_records')
status = models.CharField(max_length=15, choices=STATUS_CHOICES, default='not_started')
lessons_completed_count = models.PositiveIntegerField(default=0)
started_at = models.DateTimeField(null=True, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
# آخرین فعالیت (پاسخ به سوال یا تکمیل درس) روی این Node — مبنای تشخیص
# abandoned. عمداً به‌جای auto_now دستی ست می‌شه (فقط داخل content/services.py).
last_activity_at = models.DateTimeField(null=True, blank=True, db_index=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=['user_id', 'node'], name='unique_node_progress_per_user')
]
indexes = [
models.Index(fields=['status', 'last_activity_at']),
]
def __str__(self):
return f"node-progress-user{self.user_id}-node{self.node_id}-{self.status}"
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}"

19
content/receivers.py Normal file
View File

@@ -0,0 +1,19 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import UserAnswer, UserLessonCompletion
from .services import touch_node_progress_on_answer, update_node_progress_on_lesson_completion
@receiver(post_save, sender=UserAnswer, dispatch_uid='content_touch_node_progress_on_answer')
def _on_user_answer_created(sender, instance, created, **kwargs):
if not created:
return
touch_node_progress_on_answer(instance.user_id, instance.question)
@receiver(post_save, sender=UserLessonCompletion, dispatch_uid='content_update_node_progress_on_completion')
def _on_lesson_completion_created(sender, instance, created, **kwargs):
if not created:
return
update_node_progress_on_lesson_completion(instance.user_id, instance.lesson)

119
content/serializers.py Normal file
View File

@@ -0,0 +1,119 @@
from drf_spectacular.utils import extend_schema_field
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()
# ---- Node progress (تکمیل‌شده / نصفه‌رها / در حال انجام) ----
class NodeProgressSerializer(serializers.Serializer):
nodeId = serializers.CharField(source='node_id')
nodeTitle = serializers.CharField(source='node.title')
unitId = serializers.CharField(source='node.unit_id')
status = serializers.ChoiceField(
choices=['not_started', 'in_progress', 'completed', 'abandoned'],
)
lessonsCompleted = serializers.IntegerField(source='lessons_completed_count')
lessonsTotal = serializers.SerializerMethodField()
startedAt = serializers.DateTimeField(source='started_at', allow_null=True)
completedAt = serializers.DateTimeField(source='completed_at', allow_null=True)
lastActivityAt = serializers.DateTimeField(source='last_activity_at', allow_null=True)
@extend_schema_field(serializers.IntegerField)
def get_lessonsTotal(self, obj):
return obj.node.lessons.count()
class NodeProgressListResponseSerializer(serializers.Serializer):
progress = NodeProgressSerializer(many=True)

87
content/services.py Normal file
View File

@@ -0,0 +1,87 @@
"""
منطق تغییر status روی NodeProgress. دو نقطه‌ی ورودی، هر دو از
content/receivers.py صدا زده می‌شن (نه مستقیم از views.py) تا اگه جای
دیگه‌ای هم (مثلاً یک import/bulk-completion از ai_tutor) رکورد UserAnswer
یا UserLessonCompletion ساخته بشه، وضعیت Node هم خودکار به‌روز بمونه —
دقیقاً همون الگوی signal-based که برای discount/subscription استفاده شده.
"""
from django.utils import timezone
from statuslog.signals import status_changed
from .models import Node, NodeProgress, UserLessonCompletion
def _get_or_create_progress(user_id, node):
return NodeProgress.objects.get_or_create(user_id=user_id, node=node)
def _emit_if_changed(progress, old_status, event, user_id):
if progress.status != old_status:
status_changed.send(
sender='content.NodeProgress', object_id=progress.id,
old_status=old_status, new_status=progress.status,
event=event, user_id=user_id,
)
def touch_node_progress_on_answer(user_id, question):
"""
وقتی کاربر به یک سوال پاسخ می‌ده (چه درست چه غلط)، یعنی روی Node
مربوطه فعالیت داشته. اگه Node هنوز شروع نشده یا رها شده بوده،
به in_progress برمی‌گرده.
"""
node = question.lesson.node
progress, _created = _get_or_create_progress(user_id, node)
old_status = progress.status
now = timezone.now()
if progress.status in ('not_started', 'abandoned'):
progress.status = 'in_progress'
if progress.started_at is None:
progress.started_at = now
progress.last_activity_at = now
progress.save(update_fields=['status', 'started_at', 'last_activity_at'])
_emit_if_changed(progress, old_status, event='answer_activity', user_id=user_id)
return progress
def update_node_progress_on_lesson_completion(user_id, lesson):
"""
وقتی یک Lesson تکمیل می‌شه، تعداد Lessonهای تکمیل‌شده‌ی Node رو
به‌روز می‌کنه؛ اگه همه‌ی Lessonهای Node تکمیل شده باشن، status
میره روی completed، وگرنه (یا اگه از abandoned برگشته) in_progress.
"""
node = lesson.node
progress, _created = _get_or_create_progress(user_id, node)
old_status = progress.status
now = timezone.now()
lesson_ids = list(node.lessons.values_list('id', flat=True))
completed_count = UserLessonCompletion.objects.filter(
user_id=user_id, lesson_id__in=lesson_ids
).values('lesson_id').distinct().count()
progress.lessons_completed_count = completed_count
if progress.started_at is None:
progress.started_at = now
progress.last_activity_at = now
if lesson_ids and completed_count >= len(lesson_ids):
progress.status = 'completed'
progress.completed_at = now
else:
progress.status = 'in_progress'
progress.save(update_fields=[
'lessons_completed_count', 'status', 'started_at', 'completed_at', 'last_activity_at',
])
_emit_if_changed(progress, old_status, event='lesson_completed', user_id=user_id)
return progress
def node_progress_summary(node):
"""برای سریالایزر: تعداد کل Lessonهای یک Node (لازم برای lessonsTotal)."""
return node.lessons.count()

3
content/tests.py Normal file
View File

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

21
content/urls.py Normal file
View File

@@ -0,0 +1,21 @@
from django.urls import path
from .views import (
GradesListView,
HomeView,
LessonCompleteView,
LessonIntroView,
LessonQuestionsView,
NodeProgressListView,
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'),
path('progress/nodes', NodeProgressListView.as_view(), name='node-progress-list'),
]

280
content/views.py Normal file
View File

@@ -0,0 +1,280 @@
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, NodeProgress, Question, UserAnswer, UserLessonCompletion
from .serializers import (
AnswerRequestSerializer,
AnswerResponseSerializer,
GradesListResponseSerializer,
HomeResponseSerializer,
LessonCompleteRequestSerializer,
LessonCompleteResponseSerializer,
LessonIntroResponseSerializer,
LessonQuestionsResponseSerializer,
NodeProgressListResponseSerializer,
)
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)
class NodeProgressListView(APIView):
"""
GET /api/progress/nodes?userId=&unit= — لیست وضعیتِ کاربر روی هر Node
(تکمیل‌شده/نصفه‌رها/در حال انجام). userId مثل HomeView به‌صورت query
param گرفته می‌شه چون این endpoint قراره از سمت والدین (family app،
گزارش هفتگی) و پشتیبانی هم برای کاربر دیگه (نه فقط خودِ request.user)
صدا زده بشه، نه فقط خودِ دانش‌آموز.
"""
@extend_schema(
parameters=[
OpenApiParameter('userId', OpenApiTypes.INT, required=True),
OpenApiParameter('unit', OpenApiTypes.INT, required=False),
],
responses=NodeProgressListResponseSerializer,
tags=['Curriculum (content)'],
)
def get(self, request):
user_id = request.query_params.get('userId')
if not user_id:
return Response({'detail': 'پارامتر userId الزامی است.'}, status=status.HTTP_400_BAD_REQUEST)
user_id = int(user_id)
qs = NodeProgress.objects.filter(user_id=user_id).select_related('node')
unit_id = request.query_params.get('unit')
if unit_id:
qs = qs.filter(node__unit_id=unit_id)
data = {'progress': list(qs.order_by('node__unit_id', 'node__order'))}
return Response(NodeProgressListResponseSerializer(data).data)

0
discount/__init__.py Normal file
View File

31
discount/admin.py Normal file
View File

@@ -0,0 +1,31 @@
from django.contrib import admin
from .models import DiscountCode, Offer, Redemption, UserOffer
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')
@admin.register(Offer)
class OfferAdmin(admin.ModelAdmin):
list_display = ('id', 'name', 'kind', 'duration_days', 'code', 'auto_assign_on_signup', 'is_active')
list_filter = ('kind', 'auto_assign_on_signup', 'is_active')
@admin.register(UserOffer)
class UserOfferAdmin(admin.ModelAdmin):
list_display = ('id', 'offer', 'user_id', 'source', 'status', 'granted_at', 'expires_at')
list_filter = ('source', 'status')

9
discount/apps.py Normal file
View File

@@ -0,0 +1,9 @@
from django.apps import AppConfig
class DiscountConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'discount'
def ready(self):
from . import receivers # noqa: F401

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

@@ -0,0 +1,43 @@
# Generated by Django 6.1 on 2026-08-18 02:33
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('discount', '0002_redemption_unique_redemption_per_user_per_code'),
]
operations = [
migrations.CreateModel(
name='Offer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('kind', models.CharField(choices=[('trial', 'دوره\u200cی رایگان محدود'), ('free_full', 'دسترسی کامل رایگان')], max_length=10)),
('duration_days', models.PositiveIntegerField(blank=True, null=True)),
('code', models.CharField(blank=True, max_length=30, null=True, unique=True)),
('auto_assign_on_signup', models.BooleanField(default=False)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='UserOffer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(db_index=True)),
('source', models.CharField(choices=[('signup_auto', 'خودکار هنگام ثبت\u200cنام'), ('code', 'وارد کردن کد'), ('manual', 'اختصاص دستی/پشتیبانی')], max_length=15)),
('status', models.CharField(choices=[('active', 'فعال'), ('expired', 'منقضی'), ('revoked', 'لغوشده')], default='active', max_length=10)),
('granted_at', models.DateTimeField(auto_now_add=True)),
('expires_at', models.DateTimeField(blank=True, null=True)),
('offer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_offers', to='discount.offer')),
],
options={
'ordering': ['-granted_at'],
'constraints': [models.UniqueConstraint(fields=('offer', 'user_id'), name='unique_offer_per_user')],
},
),
]

View File

100
discount/models.py Normal file
View File

@@ -0,0 +1,100 @@
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}"
class Offer(models.Model):
"""
آفر رایگان/تخفیف مستقل از DiscountCode: برخلاف DiscountCode که روی
مبلغِ یک سفارش اثر می‌ذاره، Offer دسترسی (اشتراک) رایگان می‌ده — یا
برای مدت محدود ('trial'، مثلاً یک هفته بعد از ثبت‌نام) یا کامل و
نامحدود ('free_full'). طبق درخواست سینیور، اعمال این آفرها signal-based
و قابل‌تغییره: کدوم آفر، با چه شرطی، روی کدوم کاربر اعمال بشه بدون
دست‌زدن به accounts یا subscription قابل تغییره (فقط discount/receivers.py
و discount/services.py).
"""
KIND_CHOICES = [
('trial', 'دوره‌ی رایگان محدود'),
('free_full', 'دسترسی کامل رایگان'),
]
name = models.CharField(max_length=100)
kind = models.CharField(max_length=10, choices=KIND_CHOICES)
# فقط برای kind='trial' معنی داره؛ برای free_full خالی می‌مونه (یعنی نامحدود).
duration_days = models.PositiveIntegerField(null=True, blank=True)
# اگه پر باشه، کاربر می‌تونه بعداً (نه فقط هنگام ثبت‌نام) با این کد آفر
# رو خودش اعمال کنه؛ دقیقاً مشابه DiscountCode.code ولی برای اشتراک،
# نه تخفیفِ یک سفارش.
code = models.CharField(max_length=30, unique=True, null=True, blank=True)
# اگه True باشه، همون لحظه‌ی signal اول (accounts.signals.user_registered)
# بدون نیاز به وارد کردن کد، خودکار به کاربر تازه‌ثبت‌نام‌شده اختصاص داده می‌شه.
auto_assign_on_signup = models.BooleanField(default=False)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
class UserOffer(models.Model):
"""ثبتِ اینکه کدوم آفر به کدوم کاربر، از چه مسیری، چه زمانی اختصاص داده شده."""
SOURCE_CHOICES = [
('signup_auto', 'خودکار هنگام ثبت‌نام'),
('code', 'وارد کردن کد'),
('manual', 'اختصاص دستی/پشتیبانی'),
]
STATUS_CHOICES = [
('active', 'فعال'),
('expired', 'منقضی'),
('revoked', 'لغوشده'),
]
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
offer = models.ForeignKey(Offer, on_delete=models.CASCADE, related_name='user_offers')
source = models.CharField(max_length=15, choices=SOURCE_CHOICES)
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='active')
granted_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField(null=True, blank=True) # None یعنی نامحدود (free_full)
class Meta:
ordering = ['-granted_at']
# هر کاربر برای یک آفرِ مشخص فقط یک‌بار می‌تونه دریافت کنه (چه
# auto چه با کد)؛ جلوی گرفتن مکرر یک آفرِ trial رو می‌گیره.
constraints = [
models.UniqueConstraint(fields=['offer', 'user_id'], name='unique_offer_per_user')
]
def __str__(self):
return f"useroffer-{self.offer_id}-user{self.user_id}-{self.status}"

20
discount/receivers.py Normal file
View File

@@ -0,0 +1,20 @@
"""
گیرنده‌ی سیگنال ثبت‌نام (accounts.signals.user_registered). accounts هیچ‌وقت
از وجود discount/Offer خبر نداره؛ این فایل فقط توی apps.py همین اپ (discount)
connect می‌شه. برای تغییر منطق «کدوم آفر به کی تعلق بگیره»، فقط همین‌جا و
discount/services.py رو لمس کنید — دقیقاً همون چیزی که سینیور خواسته بود.
"""
from accounts.signals import user_registered
from .models import Offer
from .services import apply_offer
def _assign_signup_offers(sender, **kwargs):
user_id = kwargs['user_id']
offers = Offer.objects.filter(auto_assign_on_signup=True, is_active=True)
for offer in offers:
apply_offer(user_id=user_id, offer=offer, source='signup_auto')
user_registered.connect(_assign_signup_offers, dispatch_uid='discount_assign_signup_offers')

31
discount/serializers.py Normal file
View File

@@ -0,0 +1,31 @@
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()
class RedeemOfferCodeRequestSerializer(serializers.Serializer):
code = serializers.CharField()
class UserOfferResponseSerializer(serializers.Serializer):
id = serializers.IntegerField()
offerName = serializers.CharField()
kind = serializers.CharField()
status = serializers.CharField()
grantedAt = serializers.DateTimeField()
expiresAt = serializers.DateTimeField(allow_null=True)

56
discount/services.py Normal file
View File

@@ -0,0 +1,56 @@
"""
منطق آفرهای رایگان (Offer/UserOffer)، جدا از منطق موجود DiscountCode
(تخفیف روی مبلغ سفارش). اینجا محل تنها جاییه که تصمیم می‌گیره «اعمال یک
آفر» دقیقاً یعنی چی (الان: فعال‌سازی اشتراک رایگان/محدود) — طبق درخواست
سینیور برای signal-based و قابل‌تغییر بودنِ این منطق، تغییر دادنِ رفتار
آفرها (مثلاً بجای اشتراک، دسترسی به یک اپ خاص) فقط همین فایل رو لمس می‌کنه،
نه apps.py/receivers.py و نه هیچ اپ دیگه‌ای.
"""
from django.db import IntegrityError
from rest_framework.exceptions import ValidationError
from .models import Offer, UserOffer
def apply_offer(*, user_id: int, offer: Offer, source: str) -> UserOffer | None:
"""
یک آفر رو به یک کاربر اختصاص می‌ده و اثرش رو اعمال می‌کنه (فعلاً: اشتراک
رایگان/محدود). اگه کاربر قبلاً همین آفر رو گرفته باشه (unique constraint)
None برمی‌گردونه، نه خطا — چون auto-assign هنگام ثبت‌نام نباید کرش کنه.
"""
if not offer.is_active:
return None
try:
user_offer = UserOffer.objects.create(
user_id=user_id, offer=offer, source=source,
expires_at=None, # پایین‌تر بعد از فعال‌سازی اشتراک ست می‌شه
)
except IntegrityError:
return None
from subscription.services import grant_free_subscription
duration_days = offer.duration_days if offer.kind == 'trial' else None
subscription = grant_free_subscription(
user_id, duration_days=duration_days, event=f'offer_{offer.kind}',
)
user_offer.expires_at = subscription.expires_at
user_offer.save(update_fields=['expires_at'])
return user_offer
def redeem_offer_code(*, user_id: int, code: str) -> UserOffer:
"""POST /api/discount/offers/redeem — اعمال آفر با کد، هر زمان بعد از ثبت‌نام."""
offer = Offer.objects.filter(code=code, is_active=True).first()
if offer is None:
raise ValidationError({'code': 'این کد آفر معتبر نیست.'})
if UserOffer.objects.filter(offer=offer, user_id=user_id).exists():
raise ValidationError({'code': 'شما قبلاً این آفر را دریافت کرده‌اید.'})
user_offer = apply_offer(user_id=user_id, offer=offer, source='code')
if user_offer is None:
raise ValidationError({'code': 'این کد آفر معتبر نیست یا غیرفعال شده است.'})
return user_offer

3
discount/tests.py Normal file
View File

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

15
discount/urls.py Normal file
View File

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

186
discount/views.py Normal file
View File

@@ -0,0 +1,186 @@
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 (
RedeemOfferCodeRequestSerializer,
RedeemRequestSerializer,
RedeemResponseSerializer,
UserOfferResponseSerializer,
ValidateResponseSerializer,
)
from .services import redeem_offer_code
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)
class RedeemOfferCodeView(APIView):
"""
POST /api/discount/offers/redeem — اعمال آفر (اشتراک رایگان/محدود) با کد،
برخلاف auto_assign_on_signup که خودکارِ هنگام ثبت‌نام‌ه، این برای زمانی‌ه
که سینیور بخواد یک آفر رو بعداً (نه فقط هنگام ثبت‌نام) با یک کد به کاربر
بده — مثلاً یک کمپین تبلیغاتی.
"""
permission_classes = [IsAuthenticated]
@extend_schema(
request=RedeemOfferCodeRequestSerializer, responses=UserOfferResponseSerializer,
tags=['Discount (discount)'],
)
def post(self, request):
serializer = RedeemOfferCodeRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user_offer = redeem_offer_code(user_id=request.user.id, code=serializer.validated_data['code'])
data = {
'id': user_offer.id,
'offerName': user_offer.offer.name,
'kind': user_offer.offer.kind,
'status': user_offer.status,
'grantedAt': user_offer.granted_at,
'expiresAt': user_offer.expires_at,
}
return Response(UserOfferResponseSerializer(data).data)
class MyOffersView(APIView):
"""GET /api/discount/offers/mine — لیست آفرهایی که کاربر گرفته (خودکار یا با کد)"""
permission_classes = [IsAuthenticated]
@extend_schema(responses=UserOfferResponseSerializer(many=True), tags=['Discount (discount)'])
def get(self, request):
from .models import UserOffer
user_offers = UserOffer.objects.filter(user_id=request.user.id).select_related('offer')
data = [
{
'id': uo.id,
'offerName': uo.offer.name,
'kind': uo.offer.kind,
'status': uo.status,
'grantedAt': uo.granted_at,
'expiresAt': uo.expires_at,
}
for uo in user_offers
]
return Response(UserOfferResponseSerializer(data, many=True).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', 'provider', 'created_at')
list_filter = ('status', 'provider', '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'

105
payment/bazaar.py Normal file
View File

@@ -0,0 +1,105 @@
"""
کلاینت سرور-به-سرورِ پرداخت درون‌برنامه‌ای بازار (REST API v2)، طبق داکیومنت
رسمی: developers.cafebazaar.ir/fa/guidelines/in-app-billing
جریان رسمی:
۱) یک‌بار (دستی، از پنل devapi توسعه‌دهنده) با CLIENT_ID/CLIENT_SECRET یک
authorization code گرفته می‌شه و با اون REFRESH_TOKEN ساخته می‌شه —
این مرحله در این فایل نیست چون یک‌بار و دستی‌ست، نه بخشی از API عمومی.
۲) بک‌اند با همون REFRESH_TOKEN، هر بار (یا هر بار که access_token منقضی
شده) یک ACCESS_TOKEN تازه می‌گیره (POST /auth/token با
grant_type=refresh_token).
۳) با همون access_token، وضعیت خرید/اشتراک از endpoint purchase/subscription
خونده می‌شه (GET .../applications/{package}/inapp/{sku}/purchases/{token}/).
⚠️ کافه‌بازار روش صحت‌سنجی رو تغییر داده (نگاه کنید به
developers.cafebazaar.ir/fa/guidelines/in-app-billing/api/postman-and-bazaar-api-new)؛
قبل از اتصال واقعی به production، دقیق‌ترین مسیر endpoint را از همون صفحه یا
پنل devapi خودتون تایید کنید — این فایل فقط اسکلتِ همون جریان رسمی رو
پیاده‌سازی کرده تا بدون کلید واقعی هم (مثل call_payment_gateway در
services.py) از طریق Swagger قابل تست باشه.
"""
from dataclasses import dataclass
import requests
from django.conf import settings
from django.core.cache import cache
ACCESS_TOKEN_CACHE_KEY = 'bazaar_iap_access_token'
@dataclass
class BazaarPurchaseResult:
is_valid: bool
purchase_state: int | None = None # ۰=خریداری‌شده، ۱=لغوشده (طبق سند بازار)
consumption_state: int | None = None
developer_payload: str = ''
raw: dict | None = None
def _get_access_token() -> str:
"""
ACCESS_TOKEN رو کش می‌کنه (توی cache framework جنگو) تا هر verify یک
درخواست OAuth جدا نزنه؛ اگه کش نداشت یا منقضی شده بود، با REFRESH_TOKEN
یکی تازه می‌گیره.
"""
cached = cache.get(ACCESS_TOKEN_CACHE_KEY)
if cached:
return cached
if not settings.BAZAAR_REFRESH_TOKEN:
# بدون کلید واقعی (مثلاً محیط توسعه)، استاب: دقیقاً مثل
# call_payment_gateway در payment/services.py، تا جریان کامل بدون
# کلید واقعی بازار هم قابل تست باشه.
return 'stub-access-token'
resp = requests.post(
f'{settings.BAZAAR_API_BASE}/auth/token/',
data={
'grant_type': 'refresh_token',
'client_id': settings.BAZAAR_CLIENT_ID,
'client_secret': settings.BAZAAR_CLIENT_SECRET,
'refresh_token': settings.BAZAAR_REFRESH_TOKEN,
},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
token = data['access_token']
# طبق سند بازار access_token معمولاً ~۱ ساعت اعتباره؛ کمی زودتر منقضی
# می‌کنیم تا لبه‌ی زمانی مشکل نسازه.
cache.set(ACCESS_TOKEN_CACHE_KEY, token, timeout=int(data.get('expires_in', 3600)) - 60)
return token
def verify_purchase(*, product_sku: str, purchase_token: str, is_subscription: bool = False) -> BazaarPurchaseResult:
"""
صحت‌سنجی purchaseToken که کلاینت اندروید بعد از خرید از SDK بازار گرفته.
is_subscription=True برای اشتراک‌های دوره‌ای بازار (endpoint جدا از خرید
تک‌محصولی)، طبق سند رسمی.
"""
if not settings.BAZAAR_REFRESH_TOKEN:
# استاب توسعه: هر token غیرخالی معتبر فرض می‌شه تا کل جریان
# (ساخت سفارش bazaar → verify → فعال‌سازی اشتراک) بدون کلید واقعی
# از طریق Swagger قابل تست باشه.
return BazaarPurchaseResult(is_valid=bool(purchase_token), purchase_state=0)
access_token = _get_access_token()
kind = 'subscriptions' if is_subscription else 'purchases'
url = (
f'{settings.BAZAAR_API_BASE}/api/applications/'
f'{settings.BAZAAR_PACKAGE_NAME}/{kind}/{product_sku}/{purchase_token}/'
)
resp = requests.get(url, headers={'Authorization': f'Bearer {access_token}'}, timeout=10)
if resp.status_code != 200:
return BazaarPurchaseResult(is_valid=False, raw={'status_code': resp.status_code})
data = resp.json()
purchase_state = data.get('purchaseState')
return BazaarPurchaseResult(
is_valid=purchase_state == 0, # ۰ یعنی خریداری‌شده و معتبر
purchase_state=purchase_state,
consumption_state=data.get('consumptionState'),
developer_payload=data.get('developerPayload', ''),
raw=data,
)

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

@@ -0,0 +1,28 @@
# Generated by Django 6.1 on 2026-08-18 02:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payment', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='order',
name='bazaar_product_sku',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='order',
name='bazaar_purchase_token',
field=models.CharField(blank=True, max_length=300),
),
migrations.AddField(
model_name='order',
name='provider',
field=models.CharField(choices=[('gateway', 'درگاه پرداخت'), ('bazaar', 'پرداخت درون\u200cبرنامه\u200cای بازار')], default='gateway', max_length=10),
),
]

View File

52
payment/models.py Normal file
View File

@@ -0,0 +1,52 @@
from django.db import models
class Order(models.Model):
"""
تنها اپی که واقعاً پول جابه‌جا می‌کنه. عمداً از Subscription جدا شده
چون منطق و ریسکش فرق داره (درگاه، retry، idempotency).
"""
STATUS_CHOICES = [('pending', 'در انتظار'), ('paid', 'پرداخت‌شده'), ('failed', 'ناموفق')]
# دو مدل پرداخت طبق درخواست سینیور: 'gateway' (زرین‌پال/زیبال و مشابه) و
# 'bazaar' (پرداخت درون‌برنامه‌ای اپلیکیشن بازار). این دو منطق پرداخت و
# صحت‌سنجی کاملاً متفاوتی دارن (webhook درگاه در برابر verify سرور-به-
# سرور با access_token بازار)؛ برای همین provider جداست، نه فقط یک مقدار
# دیگه در Transaction.gateway (که فقط برای گزارش‌گیریِ درگاه واقعی زرین‌پال/
# زیبال باقی می‌مونه).
PROVIDER_CHOICES = [('gateway', 'درگاه پرداخت'), ('bazaar', 'پرداخت درون‌برنامه‌ای بازار')]
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')
provider = models.CharField(max_length=10, choices=PROVIDER_CHOICES, default='gateway')
# فقط وقتی provider == 'bazaar' پر می‌شن: SKU محصول درون‌برنامه‌ای تعریف‌شده
# در پنل بازار، و purchase token‌ای که کلاینت اندروید بعد از خرید از SDK
# بازار می‌گیره و برای verify به بک‌اند می‌فرسته.
bazaar_product_sku = models.CharField(max_length=100, blank=True)
bazaar_purchase_token = models.CharField(max_length=300, blank=True)
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}"

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