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