Compare commits

...

2 Commits

Author SHA1 Message Date
02d40e1238 Merge updated project structure with previous history 2026-08-18 16:54:53 +03:30
331148037e Initial commit - clean project structure 2026-08-18 16:48:15 +03:30
43 changed files with 1183 additions and 119 deletions

101
README.md
View File

@@ -1,72 +1,63 @@
Tadrisino API
# Tadrisino API
این پروژه بخش API و سرویس‌های سمت سرور سامانه Tadrisino را شامل می‌شود و ساختار آن به‌صورت ماژولار طراحی شده است.
بک‌اند اپلیکیشن آموزشی Tadrisino — Django + Django REST Framework.
ساختار پروژه به گونه‌ای است که هر بخش اصلی سیستم در یک اپلیکیشن مستقل قرار گرفته و مدل‌ها، منطق مربوط به API، Serializerها، Viewها، URLها و Migrationهای موردنیاز هر بخش در همان اپ مدیریت می‌شوند.
احراز هویت با OTP، محتوای درسی و پیشرفت کاربر، دستیار هوش مصنوعی، اشتراک و پرداخت،
تخفیف، اعلان‌ها، همکاری در فروش، پشتیبانی و حساب خانواده.
اجرای پروژه
ابتدا محیط مجازی را ایجاد و فعال کنید:
## اجرا
```bash
python3 -m venv venv
source venv/bin/activate
سپس وابستگی‌های پروژه را نصب کنید:
source venv/bin/activate # ویندوز: venv\Scripts\activate
pip install -r requirements.txt
اجرای Migrationها:
python manage.py migrate
در صورت نیاز می‌توان یک کاربر مدیریتی ایجاد کرد:
python manage.py createsuperuser
در نهایت پروژه را اجرا کنید:
python manage.py createsuperuser # اختیاری، برای /admin
python manage.py runserver
```
مستندات API
## مستندات API
پس از اجرای پروژه، مستندات API از طریق Swagger در دسترس است:
| | |
|---|---|
| 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/` |
http://127.0.0.1:8000/api/docs/
**شروع سریع در 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` امتحان کنید.
مستندات Redoc:
## ساختار پروژه
http://127.0.0.1:8000/api/redoc/
| اپ | مسئولیت | مدل‌های اصلی |
|---|---|---|
| `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 های اصلی پروژه | — |
پنل مدیریت:
## نکات فنی مهم
http://127.0.0.1:8000/admin/
- **احراز هویت**: 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` مقیّد شود.**
ساختار پروژه
## قبل از دیپلوی پروداکشن
config/
accounts/
content/
progress/
ai_tutor/
subscription/
payment/
discount/
affiliate/
support/
notifications/
family/
بخش‌های مختلف پروژه شامل مدیریت حساب کاربری، محتوای آموزشی، پیشرفت و عملکرد کاربر، سیستم آموزشی، اشتراک، پرداخت، تخفیف، همکاری در فروش، پشتیبانی، اعلان‌ها و مدیریت اعضای خانواده هستند.
نکات فنی
* ساختار پروژه به‌صورت ماژولار و قابل توسعه طراحی شده است.
* APIها از طریق مسیرهای مشخص و مستند قابل دسترسی هستند.
* احراز هویت و مدیریت Session برای استفاده از APIها در نظر گرفته شده است.
* پنل مدیریت برای مدیریت داده‌های اصلی سیستم فراهم شده است.
* Migrationهای مربوط به مدل‌های پروژه در هر اپ نگهداری می‌شوند.
* ساختار فعلی پروژه امکان توسعه و اضافه شدن قابلیت‌های جدید را بدون ایجاد تغییرات گسترده در بخش‌های موجود فراهم می‌کند.
تست
برای بررسی و تست APIها می‌توان مستقیماً از Swagger استفاده کرد. همچنین برای ایجاد و مدیریت داده‌های اولیه، پنل مدیریت Django در دسترس است.
- [ ] `DEBUG = False` و `SECRET_KEY` از env
- [ ] حذف/مقیّدسازی `DEV_MASTER_OTP` به محیط توسعه
- [ ] rate limit روی `auth/otp/request` و `auth/login`
- [ ] دیتابیس Postgres به‌جای SQLite

11
accounts/signals.py Normal file
View File

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

View File

@@ -115,7 +115,7 @@ class LoginView(APIView):
if not valid:
return Response({'detail': 'کد OTP نامعتبر یا منقضی‌شده است.'}, status=status.HTTP_400_BAD_REQUEST)
user, _ = User.objects.get_or_create(
user, created = User.objects.get_or_create(
phone=phone,
defaults={'is_guest': False, 'name': ''},
)
@@ -123,6 +123,13 @@ class LoginView(APIView):
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,

View File

@@ -65,6 +65,9 @@ INSTALLED_APPS = [
# Tadrisino apps (فاز دوم - بخش ۵: family)
'family',
# Tadrisino apps (سیستم پرداخت بازار + آفر/تخفیف + لاگ عمومی تغییر Status)
'statuslog',
]
MIDDLEWARE = [
@@ -170,15 +173,21 @@ REST_FRAMEWORK = {
SPECTACULAR_SETTINGS = {
'TITLE': 'Tadrisino API',
'DESCRIPTION': (
'API services for the Tadrisino platform, '
'including user accounts, educational content, '
'progress tracking, subscriptions, payments, '
'discounts, affiliate services, support, '
'notifications, and family management.'
'بک‌اند اپلیکیشن آموزشی 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,
},
}
# اعتبار توکن دسترسی (ثانیه) و رفرش‌توکن (روز)
@@ -189,6 +198,17 @@ REFRESH_TOKEN_TTL_DAYS = 30
# بشه لاگین رو با 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
# -----------------------------------------------------------------------
@@ -210,6 +230,23 @@ INTERNAL_SERVICE_KEY = os.environ.get('INTERNAL_SERVICE_KEY', 'dev-internal-serv
# این باید با بررسی امضای 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

View File

@@ -1,6 +1,8 @@
from django.contrib import admin
from .models import Choice, Grade, Lesson, Node, Question, Unit, UserAnswer, UserLessonCompletion
from .models import (
Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, UserAnswer, UserLessonCompletion,
)
class ChoiceInline(admin.TabularInline):
@@ -48,3 +50,13 @@ class UserLessonCompletionAdmin(admin.ModelAdmin):
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',)

View File

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

View File

View File

View File

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

View File

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

View File

@@ -88,6 +88,70 @@ class UserLessonCompletion(models.Model):
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')

19
content/receivers.py Normal file
View File

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

View File

@@ -1,3 +1,4 @@
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from .models import Choice, Grade, Lesson, Question
@@ -92,3 +93,27 @@ class LessonCompleteResponseSerializer(serializers.Serializer):
xpEarned = serializers.IntegerField()
coinsEarned = serializers.IntegerField()
stats = StatsSerializer()
# ---- Node progress (تکمیل‌شده / نصفه‌رها / در حال انجام) ----
class NodeProgressSerializer(serializers.Serializer):
nodeId = serializers.CharField(source='node_id')
nodeTitle = serializers.CharField(source='node.title')
unitId = serializers.CharField(source='node.unit_id')
status = serializers.ChoiceField(
choices=['not_started', 'in_progress', 'completed', 'abandoned'],
)
lessonsCompleted = serializers.IntegerField(source='lessons_completed_count')
lessonsTotal = serializers.SerializerMethodField()
startedAt = serializers.DateTimeField(source='started_at', allow_null=True)
completedAt = serializers.DateTimeField(source='completed_at', allow_null=True)
lastActivityAt = serializers.DateTimeField(source='last_activity_at', allow_null=True)
@extend_schema_field(serializers.IntegerField)
def get_lessonsTotal(self, obj):
return obj.node.lessons.count()
class NodeProgressListResponseSerializer(serializers.Serializer):
progress = NodeProgressSerializer(many=True)

87
content/services.py Normal file
View File

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

View File

@@ -6,6 +6,7 @@ from .views import (
LessonCompleteView,
LessonIntroView,
LessonQuestionsView,
NodeProgressListView,
QuestionAnswerView,
)
@@ -16,4 +17,5 @@ urlpatterns = [
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'),
]

View File

@@ -9,7 +9,7 @@ from rest_framework.views import APIView
from progress.models import UserHearts, UserStats
from .models import Choice, Grade, Lesson, Question, UserAnswer, UserLessonCompletion
from .models import Choice, Grade, Lesson, NodeProgress, Question, UserAnswer, UserLessonCompletion
from .serializers import (
AnswerRequestSerializer,
AnswerResponseSerializer,
@@ -19,6 +19,7 @@ from .serializers import (
LessonCompleteResponseSerializer,
LessonIntroResponseSerializer,
LessonQuestionsResponseSerializer,
NodeProgressListResponseSerializer,
)
@@ -245,3 +246,35 @@ class LessonCompleteView(APIView):
'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)

View File

@@ -1,6 +1,6 @@
from django.contrib import admin
from .models import DiscountCode, Redemption
from .models import DiscountCode, Offer, Redemption, UserOffer
class RedemptionInline(admin.TabularInline):
@@ -17,3 +17,15 @@ class DiscountCodeAdmin(admin.ModelAdmin):
@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')

View File

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

View File

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

View File

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

20
discount/receivers.py Normal file
View File

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

View File

@@ -16,3 +16,16 @@ class RedeemRequestSerializer(serializers.Serializer):
class RedeemResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()
discountAmount = serializers.IntegerField()
class RedeemOfferCodeRequestSerializer(serializers.Serializer):
code = serializers.CharField()
class UserOfferResponseSerializer(serializers.Serializer):
id = serializers.IntegerField()
offerName = serializers.CharField()
kind = serializers.CharField()
status = serializers.CharField()
grantedAt = serializers.DateTimeField()
expiresAt = serializers.DateTimeField(allow_null=True)

56
discount/services.py Normal file
View File

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

View File

@@ -1,8 +1,15 @@
from django.urls import path
from .views import RedeemDiscountView, ValidateDiscountView
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'),
]

View File

@@ -10,10 +10,13 @@ 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:
@@ -127,3 +130,57 @@ class RedeemDiscountView(APIView):
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)

View File

@@ -10,8 +10,8 @@ class TransactionInline(admin.TabularInline):
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ('id', 'user_id', 'subscription_id', 'amount', 'currency', 'status', 'created_at')
list_filter = ('status', 'currency')
list_display = ('id', 'user_id', 'subscription_id', 'amount', 'currency', 'status', 'provider', 'created_at')
list_filter = ('status', 'provider', 'currency')
inlines = [TransactionInline]

105
payment/bazaar.py Normal file
View File

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

View File

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

View File

@@ -8,11 +8,25 @@ class Order(models.Model):
"""
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)

View File

@@ -8,12 +8,33 @@ class CreateOrderRequestSerializer(serializers.Serializer):
amount = serializers.IntegerField()
currency = serializers.CharField(required=False, default='IRT')
subscriptionId = serializers.CharField(required=False, allow_null=True, allow_blank=True)
# provider='bazaar' وقتی کاربر از همون ابتدا پرداخت درون‌برنامه‌ای بازار
# رو انتخاب کرده؛ در این حالت amount باید دقیقاً برابر قیمتِ محصولِ
# تعریف‌شده در پنل بازار برای همون bazaarProductSku باشه (بازار خودش
# قیمت رو کنترل می‌کنه، نه ما).
provider = serializers.ChoiceField(choices=['gateway', 'bazaar'], required=False, default='gateway')
bazaarProductSku = serializers.CharField(required=False, allow_blank=True)
class BazaarVerifyRequestSerializer(serializers.Serializer):
orderId = serializers.CharField()
productSku = serializers.CharField(required=False, allow_blank=True)
purchaseToken = serializers.CharField()
class BazaarVerifyResponseSerializer(serializers.Serializer):
orderId = serializers.CharField()
status = serializers.CharField()
valid = serializers.BooleanField()
class CreateOrderResponseSerializer(serializers.Serializer):
orderId = serializers.CharField()
status = serializers.CharField()
paymentUrl = serializers.CharField()
# provider='bazaar' یعنی paymentUrl نداریم (کلاینت با SDK بازار خودش
# خرید می‌کنه، نه با ریدایرکت به یک URL).
paymentUrl = serializers.CharField(allow_null=True)
provider = serializers.CharField()
class WebhookRequestSerializer(serializers.Serializer):

View File

@@ -6,10 +6,13 @@
"""
from django.db import IntegrityError
from django.db import transaction as db_transaction
from django.utils import timezone
from django.utils.crypto import get_random_string
from rest_framework.exceptions import APIException
from .models import Order
from statuslog.signals import status_changed
from .models import Order, Transaction
class IdempotencyKeyConflict(APIException):
@@ -54,7 +57,8 @@ def call_payment_gateway(order: Order) -> str:
def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
subscription_id: int | None = None, idempotency_key: str | None = None):
subscription_id: int | None = None, idempotency_key: str | None = None,
provider: str = 'gateway', bazaar_product_sku: str = ''):
"""
ساخت سفارش با پشتیبانی از Idempotency-Key (بخش ۴.۳ سند API): اگه سفارشی
با همین کلید قبلاً ساخته شده باشه، همون رکورد برگردونده می‌شه، نه یک
@@ -71,7 +75,7 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
existing, user_id=user_id, amount=amount, currency=currency,
subscription_id=subscription_id,
)
payment_url = call_payment_gateway(existing)
payment_url = None if existing.provider == 'bazaar' else call_payment_gateway(existing)
return existing, payment_url
try:
@@ -82,6 +86,8 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
amount=amount,
currency=currency,
status='pending',
provider=provider,
bazaar_product_sku=bazaar_product_sku,
idempotency_key=idempotency_key,
)
except IntegrityError:
@@ -94,5 +100,67 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
subscription_id=subscription_id,
)
payment_url = call_payment_gateway(order)
# برای provider='bazaar' هیچ paymentUrlی وجود نداره: کلاینت اندروید خودش
# مستقیماً با SDK بازار (IInAppBillingService) خرید رو انجام می‌ده و بعدش
# purchaseToken رو به /api/payment/bazaar/verify می‌فرسته؛ ما اینجا فقط
# سفارش رو از قبل با bazaarProductSku ثبت می‌کنیم تا verify بتونه ازش
# امنش (amount/user) رو تطبیق بده.
payment_url = None if order.provider == 'bazaar' else call_payment_gateway(order)
return order, payment_url
def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: str,
new_status: str, event: str) -> bool:
"""
منطق مشترکِ «نتیجه‌ی پرداخت رسید»، هم برای PaymentWebhookView (درگاه
معمولی) و هم BazaarVerifyPurchaseView (پرداخت درون‌برنامه‌ای بازار).
قبلاً این منطق فقط داخل PaymentWebhookView تکرار می‌شد؛ چون بازار جریان
verify کاملاً متفاوتی داره (بدون webhook، با فراخوانی سرور-به-سرور از
کلاینت)، اما اثرات نهایی (Transaction، status، فعال‌سازی اشتراک، notify،
ثبت در statuslog) دقیقاً یکی‌ان، این تابع مشترک شده تا دو بار پیاده‌سازی
و احتمال ناهماهنگی نداشته باشیم.
idempotent: اگه Transaction مشابه (order/status/gateway_ref_id) از قبل
موجود باشه، دوباره اثر جانبی اجرا نمی‌شه. خروجی: True اگه این فراخوانی
واقعاً باعث paid شدن تازه شده باشه.
"""
already_paid = order.status == 'paid'
old_status = order.status
is_duplicate = Transaction.objects.filter(
order=order, status=new_status, gateway_ref_id=gateway_ref_id,
).exists()
if not is_duplicate:
order.status = new_status
order.save(update_fields=['status'])
Transaction.objects.create(
order=order,
gateway=gateway,
gateway_ref_id=gateway_ref_id,
status=new_status,
paid_at=timezone.now() if new_status == 'paid' else None,
)
status_changed.send(
sender='payment.Order', object_id=order.id,
old_status=old_status, new_status=new_status,
event=event, user_id=order.user_id,
)
newly_paid = new_status == 'paid' and not already_paid and not is_duplicate
if newly_paid:
if order.subscription_id:
from subscription.services import activate_subscription
activate_subscription(order.subscription_id)
from notifications.services import notify
notify(
user_id=order.user_id,
type='order_paid',
title='پرداخت با موفقیت انجام شد',
related_id=order.id,
)
return newly_paid

View File

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

View File

@@ -1,22 +1,24 @@
from django.db import transaction
from django.shortcuts import get_object_or_404
from django.utils import timezone
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from . import bazaar
from .models import Order, Transaction
from .permissions import HasInternalServiceKey, HasWebhookSecret
from .serializers import (
BazaarVerifyRequestSerializer,
BazaarVerifyResponseSerializer,
CreateOrderRequestSerializer,
CreateOrderResponseSerializer,
OrderDetailResponseSerializer,
WebhookRequestSerializer,
WebhookResponseSerializer,
)
from .services import create_order
from .services import apply_order_payment_result, create_order
class CreateOrderView(APIView):
@@ -63,8 +65,13 @@ class CreateOrderView(APIView):
currency=v.get('currency') or 'IRT',
subscription_id=int(v['subscriptionId']) if v.get('subscriptionId') else None,
idempotency_key=request.headers.get('Idempotency-Key'),
provider=v.get('provider') or 'gateway',
bazaar_product_sku=v.get('bazaarProductSku') or '',
)
data = {'orderId': str(order.id), 'status': order.status, 'paymentUrl': payment_url}
data = {
'orderId': str(order.id), 'status': order.status,
'paymentUrl': payment_url, 'provider': order.provider,
}
return Response(CreateOrderResponseSerializer(data).data)
@@ -102,53 +109,73 @@ class PaymentWebhookView(APIView):
with transaction.atomic():
order = get_object_or_404(Order.objects.select_for_update(), pk=v['orderId'])
already_paid = order.status == 'paid'
# ⚠️ اصلاح امنیتی/صحت داده: قبلاً هر بار فراخوانی webhook (حتی
# اگه درگاه به‌خاطر عدم دریافت ۲۰۰ یا هر دلیل دیگه‌ای همون
# کال‌بک رو دوباره می‌فرستاد) یک رکورد Transaction جدید می‌ساخت.
# حالا اگه رکوردی با همین order/status/gatewayRefId از قبل
# موجود باشه، تکراری در نظر گرفته می‌شه و نه Transaction جدید
# ساخته می‌شه و نه اثرات جانبی (فعال‌سازی اشتراک/notify) دوباره
# اجرا می‌شن.
is_duplicate = Transaction.objects.filter(
order=order, status=v['status'], gateway_ref_id=gateway_ref_id,
).exists()
if not is_duplicate:
order.status = v['status']
order.save(update_fields=['status'])
Transaction.objects.create(
order=order,
gateway=v.get('gateway') or 'zarinpal',
gateway_ref_id=gateway_ref_id,
status=v['status'],
paid_at=timezone.now() if v['status'] == 'paid' else None,
)
if v['status'] == 'paid' and not already_paid and not is_duplicate:
if order.subscription_id:
# فعال‌سازی اشتراک بعد از پرداخت موفق. ارتباط با اپ
# subscription هم مثل create_order یک import مستقیم به
# تابع سرویسه (رفرنس سبک با id، نه FK واقعی و نه HTTP
# call به خودمون).
from subscription.services import activate_subscription
activate_subscription(order.subscription_id)
# رویداد order_paid طبق سند notifications باید ساطع بشه؛
# قبلاً این فراخوانی جا افتاده بود (فقط اشتراک فعال می‌شد).
from notifications.services import notify
notify(
user_id=order.user_id,
type='order_paid',
title='پرداخت با موفقیت انجام شد',
related_id=order.id,
)
# ⚠️ اصلاح امنیتی/صحت داده (قبلی): هر بار فراخوانی webhook (حتی
# تکراری از سمت درگاه) یک Transaction جدید می‌ساخت. حالا این
# idempotency + اثرات جانبی (فعال‌سازی اشتراک/notify/statuslog)
# داخل apply_order_payment_result مشترکه (هم اینجا هم بازار).
apply_order_payment_result(
order=order,
gateway=v.get('gateway') or 'zarinpal',
gateway_ref_id=gateway_ref_id,
new_status=v['status'],
event='gateway_webhook',
)
return Response(WebhookResponseSerializer({'received': True}).data)
class BazaarVerifyPurchaseView(APIView):
"""
POST /api/payment/bazaar/verify — تایید پرداخت درون‌برنامه‌ای بازار
برخلاف درگاه معمولی که با webhook خودِ درگاه تایید می‌شه، در پرداخت
درون‌برنامه‌ای بازار هیچ webhookی نیست: کلاینت اندروید بعد از خرید از
SDK بازار (IInAppBillingService)، یک purchaseToken می‌گیره و باید همون
رو برای این endpoint بفرسته تا بک‌اند با فراخوانی سرور-به-سرور به API
رسمی بازار (bazaar.verify_purchase)، صحتش رو تایید کنه — مشابه دقیقاً
همون چیزی که سند رسمی بازار توصیه می‌کنه: «هیچ‌وقت فقط به گفته‌ی کلاینت
اعتماد نکنید، حتماً سمت سرور verify کنید».
"""
permission_classes = [IsAuthenticated]
@extend_schema(
request=BazaarVerifyRequestSerializer,
responses=BazaarVerifyResponseSerializer,
tags=['Payment (payment)'],
)
def post(self, request):
serializer = BazaarVerifyRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
with transaction.atomic():
order = get_object_or_404(
Order.objects.select_for_update(),
pk=v['orderId'], user_id=request.user.id, provider='bazaar',
)
result = bazaar.verify_purchase(
product_sku=order.bazaar_product_sku or v['productSku'],
purchase_token=v['purchaseToken'],
)
new_status = 'paid' if result.is_valid else 'failed'
order.bazaar_purchase_token = v['purchaseToken']
order.save(update_fields=['bazaar_purchase_token'])
apply_order_payment_result(
order=order,
gateway='bazaar',
gateway_ref_id=v['purchaseToken'],
new_status=new_status,
event='bazaar_verify',
)
data = {'orderId': str(order.id), 'status': order.status, 'valid': result.is_valid}
return Response(BazaarVerifyResponseSerializer(data).data)
class OrderDetailView(APIView):
"""GET /api/payment/orders/{id} — وضعیت سفارش"""

View File

@@ -1,3 +1,4 @@
Django==5.2.17
djangorestframework==3.18.0
drf-spectacular==0.30.0
requests==2.33.1

0
statuslog/__init__.py Normal file
View File

10
statuslog/admin.py Normal file
View File

@@ -0,0 +1,10 @@
from django.contrib import admin
from .models import StatusChangeLog
@admin.register(StatusChangeLog)
class StatusChangeLogAdmin(admin.ModelAdmin):
list_display = ('id', 'label', 'object_id', 'old_status', 'new_status', 'event', 'user_id', 'created_at')
list_filter = ('label', 'event', 'new_status')
search_fields = ('object_id', 'user_id')

9
statuslog/apps.py Normal file
View File

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

View File

@@ -0,0 +1,30 @@
# Generated by Django 6.1 on 2026-08-18 02:26
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='StatusChangeLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('label', models.CharField(db_index=True, max_length=50)),
('object_id', models.IntegerField(db_index=True)),
('old_status', models.CharField(blank=True, max_length=30)),
('new_status', models.CharField(max_length=30)),
('event', models.CharField(max_length=50)),
('user_id', models.IntegerField(blank=True, db_index=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['-created_at'],
},
),
]

View File

23
statuslog/models.py Normal file
View File

@@ -0,0 +1,23 @@
from django.db import models
class StatusChangeLog(models.Model):
"""
لاگ عمومیِ تغییر Status هر مدلی در پروژه (Order، Subscription، UserOffer و...)
از طریق سیگنال statuslog.signals.status_changed ساخته می‌شه، نه مستقیم.
label به‌جای FK واقعی، چون این اپ نباید به هیچ اپ دیگه‌ای وابسته بشه
(دقیقاً همون الگوی ارجاع سبک با id که در کل پروژه استفاده شده).
"""
label = models.CharField(max_length=50, db_index=True) # مثل 'payment.Order'
object_id = models.IntegerField(db_index=True)
old_status = models.CharField(max_length=30, blank=True)
new_status = models.CharField(max_length=30)
event = models.CharField(max_length=50) # چه رویدادی باعث تغییر شده
user_id = models.IntegerField(null=True, blank=True, db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f"{self.label}#{self.object_id}: {self.old_status}→{self.new_status} ({self.event})"

16
statuslog/receivers.py Normal file
View File

@@ -0,0 +1,16 @@
from .models import StatusChangeLog
from .signals import status_changed
def _log_status_change(sender, **kwargs):
StatusChangeLog.objects.create(
label=sender,
object_id=kwargs['object_id'],
old_status=kwargs.get('old_status', ''),
new_status=kwargs['new_status'],
event=kwargs['event'],
user_id=kwargs.get('user_id'),
)
status_changed.connect(_log_status_change, dispatch_uid='statuslog_log_status_change')

27
statuslog/signals.py Normal file
View File

@@ -0,0 +1,27 @@
"""
سیگنال عمومیِ تغییر Status، مستقل از هر اپ خاص (مثل الگوی notifications که
هیچ اپی بهش رفرنس نمی‌ده، ولی برعکسش: اینجا خودِ اپ‌ها چیزی از statuslog
import نمی‌کنن جز همین سیگنال، و منطق ثبت لاگ کاملاً داخل statuslog می‌مونه).
هر اپی که یک فیلد status داره (Order، Subscription، UserOffer، در آینده
پیشرفت درس/دوره و ...) موقع تغییر status باید این سیگنال رو send کنه:
from statuslog.signals import status_changed
status_changed.send(
sender='payment.Order', # رشته‌ی label مدل، نه خودِ کلاس
object_id=order.id,
old_status=old_status,
new_status=order.status,
event='payment_webhook_paid', # چه اتفاقی باعث تغییر شده
user_id=order.user_id, # اختیاری
)
چون send با sender رشته‌ای (نه کلاس مدل) صدا زده می‌شه، هیچ اپی لازم نیست
مدل واقعیِ اپ دیگه رو import کنه؛ دقیقاً همون اصل «ارجاع سبک، بدون FK واقعی»
که در کل پروژه رعایت شده.
"""
from django.dispatch import Signal
# kwargs: sender (str, مثل 'payment.Order')، object_id، old_status، new_status،
# event، user_id (اختیاری)
status_changed = Signal()

View File

@@ -1,5 +1,7 @@
from django.utils import timezone
from statuslog.signals import status_changed
from .models import Subscription
@@ -16,8 +18,42 @@ def activate_subscription(subscription_id: int):
except Subscription.DoesNotExist:
return None
old_status = subscription.status
duration_days = subscription.plan.duration_days if subscription.plan else 30
subscription.status = 'active'
subscription.expires_at = timezone.now() + timezone.timedelta(days=duration_days)
subscription.save(update_fields=['status', 'expires_at'])
status_changed.send(
sender='subscription.Subscription', object_id=subscription.id,
old_status=old_status, new_status='active',
event='payment_confirmed', user_id=subscription.user_id,
)
return subscription
def grant_free_subscription(user_id: int, *, duration_days: int | None = None, event: str = 'offer_applied'):
"""
فعال‌سازی اشتراک بدون هیچ Order/پرداختی — برای Offer (آفر رایگان
محدود/کامل). duration_days=None یعنی نامحدود (free_full)؛ عدد یعنی
trial با همون تعداد روز.
از discount/services.py (apply_offer) صدا زده می‌شه؛ مثل بقیه‌ی
ارتباطات بین‌اپیِ این پروژه، یک import مستقیم به تابع سرویس، نه FK
واقعی و نه HTTP call به خودمون.
"""
subscription, _ = Subscription.objects.get_or_create(user_id=user_id)
old_status = subscription.status
subscription.status = 'active'
subscription.expires_at = (
timezone.now() + timezone.timedelta(days=duration_days) if duration_days else None
)
subscription.save(update_fields=['status', 'expires_at'])
status_changed.send(
sender='subscription.Subscription', object_id=subscription.id,
old_status=old_status, new_status='active',
event=event, user_id=user_id,
)
return subscription