Compare commits
2 Commits
ab1560aba5
...
02d40e1238
| Author | SHA1 | Date | |
|---|---|---|---|
| 02d40e1238 | |||
| 331148037e |
101
README.md
101
README.md
@@ -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
|
python3 -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate # ویندوز: venv\Scripts\activate
|
||||||
|
|
||||||
سپس وابستگیهای پروژه را نصب کنید:
|
|
||||||
|
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
اجرای Migrationها:
|
|
||||||
|
|
||||||
python manage.py migrate
|
python manage.py migrate
|
||||||
|
python manage.py createsuperuser # اختیاری، برای /admin
|
||||||
در صورت نیاز میتوان یک کاربر مدیریتی ایجاد کرد:
|
|
||||||
|
|
||||||
python manage.py createsuperuser
|
|
||||||
|
|
||||||
در نهایت پروژه را اجرا کنید:
|
|
||||||
|
|
||||||
python manage.py runserver
|
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/
|
- [ ] `DEBUG = False` و `SECRET_KEY` از env
|
||||||
accounts/
|
- [ ] حذف/مقیّدسازی `DEV_MASTER_OTP` به محیط توسعه
|
||||||
content/
|
- [ ] rate limit روی `auth/otp/request` و `auth/login`
|
||||||
progress/
|
- [ ] دیتابیس Postgres بهجای SQLite
|
||||||
ai_tutor/
|
|
||||||
subscription/
|
|
||||||
payment/
|
|
||||||
discount/
|
|
||||||
affiliate/
|
|
||||||
support/
|
|
||||||
notifications/
|
|
||||||
family/
|
|
||||||
|
|
||||||
بخشهای مختلف پروژه شامل مدیریت حساب کاربری، محتوای آموزشی، پیشرفت و عملکرد کاربر، سیستم آموزشی، اشتراک، پرداخت، تخفیف، همکاری در فروش، پشتیبانی، اعلانها و مدیریت اعضای خانواده هستند.
|
|
||||||
|
|
||||||
نکات فنی
|
|
||||||
|
|
||||||
* ساختار پروژه بهصورت ماژولار و قابل توسعه طراحی شده است.
|
|
||||||
* APIها از طریق مسیرهای مشخص و مستند قابل دسترسی هستند.
|
|
||||||
* احراز هویت و مدیریت Session برای استفاده از APIها در نظر گرفته شده است.
|
|
||||||
* پنل مدیریت برای مدیریت دادههای اصلی سیستم فراهم شده است.
|
|
||||||
* Migrationهای مربوط به مدلهای پروژه در هر اپ نگهداری میشوند.
|
|
||||||
* ساختار فعلی پروژه امکان توسعه و اضافه شدن قابلیتهای جدید را بدون ایجاد تغییرات گسترده در بخشهای موجود فراهم میکند.
|
|
||||||
|
|
||||||
تست
|
|
||||||
|
|
||||||
برای بررسی و تست APIها میتوان مستقیماً از Swagger استفاده کرد. همچنین برای ایجاد و مدیریت دادههای اولیه، پنل مدیریت Django در دسترس است.
|
|
||||||
|
|||||||
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()
|
||||||
@@ -115,7 +115,7 @@ class LoginView(APIView):
|
|||||||
if not valid:
|
if not valid:
|
||||||
return Response({'detail': 'کد OTP نامعتبر یا منقضیشده است.'}, status=status.HTTP_400_BAD_REQUEST)
|
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,
|
phone=phone,
|
||||||
defaults={'is_guest': False, 'name': ''},
|
defaults={'is_guest': False, 'name': ''},
|
||||||
)
|
)
|
||||||
@@ -123,6 +123,13 @@ class LoginView(APIView):
|
|||||||
user.is_guest = False
|
user.is_guest = False
|
||||||
user.save(update_fields=['is_guest'])
|
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
|
refresh_token = uuid.uuid4().hex
|
||||||
Session.objects.create(
|
Session.objects.create(
|
||||||
user=user,
|
user=user,
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ INSTALLED_APPS = [
|
|||||||
|
|
||||||
# Tadrisino apps (فاز دوم - بخش ۵: family)
|
# Tadrisino apps (فاز دوم - بخش ۵: family)
|
||||||
'family',
|
'family',
|
||||||
|
|
||||||
|
# Tadrisino apps (سیستم پرداخت بازار + آفر/تخفیف + لاگ عمومی تغییر Status)
|
||||||
|
'statuslog',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
@@ -170,15 +173,21 @@ REST_FRAMEWORK = {
|
|||||||
SPECTACULAR_SETTINGS = {
|
SPECTACULAR_SETTINGS = {
|
||||||
'TITLE': 'Tadrisino API',
|
'TITLE': 'Tadrisino API',
|
||||||
'DESCRIPTION': (
|
'DESCRIPTION': (
|
||||||
'API services for the Tadrisino platform, '
|
'بکاند اپلیکیشن آموزشی Tadrisino — احراز هویت با OTP، محتوای درسی '
|
||||||
'including user accounts, educational content, '
|
'و پیشرفت کاربر، دستیار هوش مصنوعی، اشتراک و پرداخت، تخفیف، '
|
||||||
'progress tracking, subscriptions, payments, '
|
'اعلانها، همکاری در فروش، پشتیبانی و حساب خانواده.'
|
||||||
'discounts, affiliate services, support, '
|
|
||||||
'notifications, and family management.'
|
|
||||||
),
|
),
|
||||||
'VERSION': '1.0.0',
|
'VERSION': '1.0.0',
|
||||||
'SERVE_INCLUDE_SCHEMA': False,
|
'SERVE_INCLUDE_SCHEMA': False,
|
||||||
'COMPONENT_SPLIT_REQUEST': True,
|
'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 تست کرد. در پروداکشن حتماً حذف بشه.
|
# بشه لاگین رو با Swagger تست کرد. در پروداکشن حتماً حذف بشه.
|
||||||
DEV_MASTER_OTP = '0000'
|
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
|
# Payment security
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
@@ -210,6 +230,23 @@ INTERNAL_SERVICE_KEY = os.environ.get('INTERNAL_SERVICE_KEY', 'dev-internal-serv
|
|||||||
# این باید با بررسی امضای HMAC درگاه جایگزین بشه.
|
# این باید با بررسی امضای HMAC درگاه جایگزین بشه.
|
||||||
PAYMENT_WEBHOOK_SECRET = os.environ.get('PAYMENT_WEBHOOK_SECRET', 'dev-webhook-secret-change-me')
|
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
|
# Default primary key field type
|
||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from django.contrib import admin
|
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):
|
class ChoiceInline(admin.TabularInline):
|
||||||
@@ -48,3 +50,13 @@ class UserLessonCompletionAdmin(admin.ModelAdmin):
|
|||||||
class UserAnswerAdmin(admin.ModelAdmin):
|
class UserAnswerAdmin(admin.ModelAdmin):
|
||||||
list_display = ('id', 'user_id', 'question', 'choice', 'correct', 'xp_earned')
|
list_display = ('id', 'user_id', 'question', 'choice', 'correct', 'xp_earned')
|
||||||
list_filter = ('correct',)
|
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',)
|
||||||
|
|||||||
@@ -4,3 +4,6 @@ from django.apps import AppConfig
|
|||||||
class ContentConfig(AppConfig):
|
class ContentConfig(AppConfig):
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
name = 'content'
|
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} روز).'
|
||||||
|
))
|
||||||
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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -88,6 +88,70 @@ class UserLessonCompletion(models.Model):
|
|||||||
return f"completion-user{self.user_id}-lesson{self.lesson_id}"
|
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):
|
class UserAnswer(models.Model):
|
||||||
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
|
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
|
||||||
question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name='answers')
|
question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name='answers')
|
||||||
|
|||||||
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)
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from drf_spectacular.utils import extend_schema_field
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from .models import Choice, Grade, Lesson, Question
|
from .models import Choice, Grade, Lesson, Question
|
||||||
@@ -92,3 +93,27 @@ class LessonCompleteResponseSerializer(serializers.Serializer):
|
|||||||
xpEarned = serializers.IntegerField()
|
xpEarned = serializers.IntegerField()
|
||||||
coinsEarned = serializers.IntegerField()
|
coinsEarned = serializers.IntegerField()
|
||||||
stats = StatsSerializer()
|
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()
|
||||||
@@ -6,6 +6,7 @@ from .views import (
|
|||||||
LessonCompleteView,
|
LessonCompleteView,
|
||||||
LessonIntroView,
|
LessonIntroView,
|
||||||
LessonQuestionsView,
|
LessonQuestionsView,
|
||||||
|
NodeProgressListView,
|
||||||
QuestionAnswerView,
|
QuestionAnswerView,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,4 +17,5 @@ urlpatterns = [
|
|||||||
path('lessons/<int:lesson_id>/questions', LessonQuestionsView.as_view(), name='lesson-questions'),
|
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('lessons/<int:lesson_id>/complete', LessonCompleteView.as_view(), name='lesson-complete'),
|
||||||
path('questions/<int:question_id>/answer', QuestionAnswerView.as_view(), name='question-answer'),
|
path('questions/<int:question_id>/answer', QuestionAnswerView.as_view(), name='question-answer'),
|
||||||
|
path('progress/nodes', NodeProgressListView.as_view(), name='node-progress-list'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from rest_framework.views import APIView
|
|||||||
|
|
||||||
from progress.models import UserHearts, UserStats
|
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 (
|
from .serializers import (
|
||||||
AnswerRequestSerializer,
|
AnswerRequestSerializer,
|
||||||
AnswerResponseSerializer,
|
AnswerResponseSerializer,
|
||||||
@@ -19,6 +19,7 @@ from .serializers import (
|
|||||||
LessonCompleteResponseSerializer,
|
LessonCompleteResponseSerializer,
|
||||||
LessonIntroResponseSerializer,
|
LessonIntroResponseSerializer,
|
||||||
LessonQuestionsResponseSerializer,
|
LessonQuestionsResponseSerializer,
|
||||||
|
NodeProgressListResponseSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -245,3 +246,35 @@ class LessonCompleteView(APIView):
|
|||||||
'stats': stats_payload,
|
'stats': stats_payload,
|
||||||
}
|
}
|
||||||
return Response(LessonCompleteResponseSerializer(data).data)
|
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)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from .models import DiscountCode, Redemption
|
from .models import DiscountCode, Offer, Redemption, UserOffer
|
||||||
|
|
||||||
|
|
||||||
class RedemptionInline(admin.TabularInline):
|
class RedemptionInline(admin.TabularInline):
|
||||||
@@ -17,3 +17,15 @@ class DiscountCodeAdmin(admin.ModelAdmin):
|
|||||||
@admin.register(Redemption)
|
@admin.register(Redemption)
|
||||||
class RedemptionAdmin(admin.ModelAdmin):
|
class RedemptionAdmin(admin.ModelAdmin):
|
||||||
list_display = ('id', 'discount_code', 'user_id', 'order_id', 'redeemed_at')
|
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')
|
||||||
|
|||||||
@@ -4,3 +4,6 @@ from django.apps import AppConfig
|
|||||||
class DiscountConfig(AppConfig):
|
class DiscountConfig(AppConfig):
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
name = 'discount'
|
name = 'discount'
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
from . import receivers # noqa: F401
|
||||||
|
|||||||
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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -32,3 +32,69 @@ class Redemption(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"redemption-{self.discount_code_id}-order{self.order_id}"
|
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')
|
||||||
@@ -16,3 +16,16 @@ class RedeemRequestSerializer(serializers.Serializer):
|
|||||||
class RedeemResponseSerializer(serializers.Serializer):
|
class RedeemResponseSerializer(serializers.Serializer):
|
||||||
success = serializers.BooleanField()
|
success = serializers.BooleanField()
|
||||||
discountAmount = serializers.IntegerField()
|
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
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import RedeemDiscountView, ValidateDiscountView
|
from .views import (
|
||||||
|
MyOffersView,
|
||||||
|
RedeemDiscountView,
|
||||||
|
RedeemOfferCodeView,
|
||||||
|
ValidateDiscountView,
|
||||||
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('discount/validate', ValidateDiscountView.as_view(), name='discount-validate'),
|
path('discount/validate', ValidateDiscountView.as_view(), name='discount-validate'),
|
||||||
path('discount/redeem', RedeemDiscountView.as_view(), name='discount-redeem'),
|
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'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ from rest_framework.views import APIView
|
|||||||
|
|
||||||
from .models import DiscountCode, Redemption
|
from .models import DiscountCode, Redemption
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
|
RedeemOfferCodeRequestSerializer,
|
||||||
RedeemRequestSerializer,
|
RedeemRequestSerializer,
|
||||||
RedeemResponseSerializer,
|
RedeemResponseSerializer,
|
||||||
|
UserOfferResponseSerializer,
|
||||||
ValidateResponseSerializer,
|
ValidateResponseSerializer,
|
||||||
)
|
)
|
||||||
|
from .services import redeem_offer_code
|
||||||
|
|
||||||
|
|
||||||
def _is_usable(discount: DiscountCode) -> bool:
|
def _is_usable(discount: DiscountCode) -> bool:
|
||||||
@@ -127,3 +130,57 @@ class RedeemDiscountView(APIView):
|
|||||||
|
|
||||||
data = {'success': True, 'discountAmount': discount_amount}
|
data = {'success': True, 'discountAmount': discount_amount}
|
||||||
return Response(RedeemResponseSerializer(data).data)
|
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)
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ class TransactionInline(admin.TabularInline):
|
|||||||
|
|
||||||
@admin.register(Order)
|
@admin.register(Order)
|
||||||
class OrderAdmin(admin.ModelAdmin):
|
class OrderAdmin(admin.ModelAdmin):
|
||||||
list_display = ('id', 'user_id', 'subscription_id', 'amount', 'currency', 'status', 'created_at')
|
list_display = ('id', 'user_id', 'subscription_id', 'amount', 'currency', 'status', 'provider', 'created_at')
|
||||||
list_filter = ('status', 'currency')
|
list_filter = ('status', 'provider', 'currency')
|
||||||
inlines = [TransactionInline]
|
inlines = [TransactionInline]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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,
|
||||||
|
)
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -8,11 +8,25 @@ class Order(models.Model):
|
|||||||
"""
|
"""
|
||||||
STATUS_CHOICES = [('pending', 'در انتظار'), ('paid', 'پرداختشده'), ('failed', 'ناموفق')]
|
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
|
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
|
||||||
subscription_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به subscription.Subscription
|
subscription_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به subscription.Subscription
|
||||||
amount = models.PositiveIntegerField()
|
amount = models.PositiveIntegerField()
|
||||||
currency = models.CharField(max_length=10, default='IRT')
|
currency = models.CharField(max_length=10, default='IRT')
|
||||||
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending')
|
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)
|
idempotency_key = models.CharField(max_length=100, unique=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,33 @@ class CreateOrderRequestSerializer(serializers.Serializer):
|
|||||||
amount = serializers.IntegerField()
|
amount = serializers.IntegerField()
|
||||||
currency = serializers.CharField(required=False, default='IRT')
|
currency = serializers.CharField(required=False, default='IRT')
|
||||||
subscriptionId = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
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):
|
class CreateOrderResponseSerializer(serializers.Serializer):
|
||||||
orderId = serializers.CharField()
|
orderId = serializers.CharField()
|
||||||
status = 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):
|
class WebhookRequestSerializer(serializers.Serializer):
|
||||||
|
|||||||
@@ -6,10 +6,13 @@
|
|||||||
"""
|
"""
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.db import transaction as db_transaction
|
from django.db import transaction as db_transaction
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.crypto import get_random_string
|
from django.utils.crypto import get_random_string
|
||||||
from rest_framework.exceptions import APIException
|
from rest_framework.exceptions import APIException
|
||||||
|
|
||||||
from .models import Order
|
from statuslog.signals import status_changed
|
||||||
|
|
||||||
|
from .models import Order, Transaction
|
||||||
|
|
||||||
|
|
||||||
class IdempotencyKeyConflict(APIException):
|
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',
|
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): اگه سفارشی
|
ساخت سفارش با پشتیبانی از 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,
|
existing, user_id=user_id, amount=amount, currency=currency,
|
||||||
subscription_id=subscription_id,
|
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
|
return existing, payment_url
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -82,6 +86,8 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
|
|||||||
amount=amount,
|
amount=amount,
|
||||||
currency=currency,
|
currency=currency,
|
||||||
status='pending',
|
status='pending',
|
||||||
|
provider=provider,
|
||||||
|
bazaar_product_sku=bazaar_product_sku,
|
||||||
idempotency_key=idempotency_key,
|
idempotency_key=idempotency_key,
|
||||||
)
|
)
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
@@ -94,5 +100,67 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
|
|||||||
subscription_id=subscription_id,
|
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
|
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
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import CreateOrderView, OrderDetailView, PaymentWebhookView
|
from .views import (
|
||||||
|
BazaarVerifyPurchaseView,
|
||||||
|
CreateOrderView,
|
||||||
|
OrderDetailView,
|
||||||
|
PaymentWebhookView,
|
||||||
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('payment/orders', CreateOrderView.as_view(), name='payment-create-order'),
|
path('payment/orders', CreateOrderView.as_view(), name='payment-create-order'),
|
||||||
path('payment/webhook', PaymentWebhookView.as_view(), name='payment-webhook'),
|
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'),
|
path('payment/orders/<int:order_id>', OrderDetailView.as_view(), name='payment-order-detail'),
|
||||||
]
|
]
|
||||||
|
|||||||
111
payment/views.py
111
payment/views.py
@@ -1,22 +1,24 @@
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils import timezone
|
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from . import bazaar
|
||||||
from .models import Order, Transaction
|
from .models import Order, Transaction
|
||||||
from .permissions import HasInternalServiceKey, HasWebhookSecret
|
from .permissions import HasInternalServiceKey, HasWebhookSecret
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
|
BazaarVerifyRequestSerializer,
|
||||||
|
BazaarVerifyResponseSerializer,
|
||||||
CreateOrderRequestSerializer,
|
CreateOrderRequestSerializer,
|
||||||
CreateOrderResponseSerializer,
|
CreateOrderResponseSerializer,
|
||||||
OrderDetailResponseSerializer,
|
OrderDetailResponseSerializer,
|
||||||
WebhookRequestSerializer,
|
WebhookRequestSerializer,
|
||||||
WebhookResponseSerializer,
|
WebhookResponseSerializer,
|
||||||
)
|
)
|
||||||
from .services import create_order
|
from .services import apply_order_payment_result, create_order
|
||||||
|
|
||||||
|
|
||||||
class CreateOrderView(APIView):
|
class CreateOrderView(APIView):
|
||||||
@@ -63,8 +65,13 @@ class CreateOrderView(APIView):
|
|||||||
currency=v.get('currency') or 'IRT',
|
currency=v.get('currency') or 'IRT',
|
||||||
subscription_id=int(v['subscriptionId']) if v.get('subscriptionId') else None,
|
subscription_id=int(v['subscriptionId']) if v.get('subscriptionId') else None,
|
||||||
idempotency_key=request.headers.get('Idempotency-Key'),
|
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)
|
return Response(CreateOrderResponseSerializer(data).data)
|
||||||
|
|
||||||
|
|
||||||
@@ -102,53 +109,73 @@ class PaymentWebhookView(APIView):
|
|||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
order = get_object_or_404(Order.objects.select_for_update(), pk=v['orderId'])
|
order = get_object_or_404(Order.objects.select_for_update(), pk=v['orderId'])
|
||||||
already_paid = order.status == 'paid'
|
# ⚠️ اصلاح امنیتی/صحت داده (قبلی): هر بار فراخوانی webhook (حتی
|
||||||
|
# تکراری از سمت درگاه) یک Transaction جدید میساخت. حالا این
|
||||||
# ⚠️ اصلاح امنیتی/صحت داده: قبلاً هر بار فراخوانی webhook (حتی
|
# idempotency + اثرات جانبی (فعالسازی اشتراک/notify/statuslog)
|
||||||
# اگه درگاه بهخاطر عدم دریافت ۲۰۰ یا هر دلیل دیگهای همون
|
# داخل apply_order_payment_result مشترکه (هم اینجا هم بازار).
|
||||||
# کالبک رو دوباره میفرستاد) یک رکورد Transaction جدید میساخت.
|
apply_order_payment_result(
|
||||||
# حالا اگه رکوردی با همین 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,
|
order=order,
|
||||||
gateway=v.get('gateway') or 'zarinpal',
|
gateway=v.get('gateway') or 'zarinpal',
|
||||||
gateway_ref_id=gateway_ref_id,
|
gateway_ref_id=gateway_ref_id,
|
||||||
status=v['status'],
|
new_status=v['status'],
|
||||||
paid_at=timezone.now() if v['status'] == 'paid' else None,
|
event='gateway_webhook',
|
||||||
)
|
|
||||||
|
|
||||||
if v['status'] == 'paid' and not already_paid and not is_duplicate:
|
|
||||||
if order.subscription_id:
|
|
||||||
# فعالسازی اشتراک بعد از پرداخت موفق. ارتباط با اپ
|
|
||||||
# subscription هم مثل create_order یک import مستقیم به
|
|
||||||
# تابع سرویسه (رفرنس سبک با id، نه FK واقعی و نه HTTP
|
|
||||||
# call به خودمون).
|
|
||||||
from subscription.services import activate_subscription
|
|
||||||
activate_subscription(order.subscription_id)
|
|
||||||
|
|
||||||
# رویداد order_paid طبق سند notifications باید ساطع بشه؛
|
|
||||||
# قبلاً این فراخوانی جا افتاده بود (فقط اشتراک فعال میشد).
|
|
||||||
from notifications.services import notify
|
|
||||||
notify(
|
|
||||||
user_id=order.user_id,
|
|
||||||
type='order_paid',
|
|
||||||
title='پرداخت با موفقیت انجام شد',
|
|
||||||
related_id=order.id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return Response(WebhookResponseSerializer({'received': True}).data)
|
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):
|
class OrderDetailView(APIView):
|
||||||
"""GET /api/payment/orders/{id} — وضعیت سفارش"""
|
"""GET /api/payment/orders/{id} — وضعیت سفارش"""
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
Django==5.2.17
|
Django==5.2.17
|
||||||
djangorestframework==3.18.0
|
djangorestframework==3.18.0
|
||||||
drf-spectacular==0.30.0
|
drf-spectacular==0.30.0
|
||||||
|
requests==2.33.1
|
||||||
|
|||||||
0
statuslog/__init__.py
Normal file
0
statuslog/__init__.py
Normal file
10
statuslog/admin.py
Normal file
10
statuslog/admin.py
Normal 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
9
statuslog/apps.py
Normal 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
|
||||||
30
statuslog/migrations/0001_initial.py
Normal file
30
statuslog/migrations/0001_initial.py
Normal 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
statuslog/migrations/__init__.py
Normal file
0
statuslog/migrations/__init__.py
Normal file
23
statuslog/models.py
Normal file
23
statuslog/models.py
Normal 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
16
statuslog/receivers.py
Normal 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
27
statuslog/signals.py
Normal 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()
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from statuslog.signals import status_changed
|
||||||
|
|
||||||
from .models import Subscription
|
from .models import Subscription
|
||||||
|
|
||||||
|
|
||||||
@@ -16,8 +18,42 @@ def activate_subscription(subscription_id: int):
|
|||||||
except Subscription.DoesNotExist:
|
except Subscription.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
old_status = subscription.status
|
||||||
duration_days = subscription.plan.duration_days if subscription.plan else 30
|
duration_days = subscription.plan.duration_days if subscription.plan else 30
|
||||||
subscription.status = 'active'
|
subscription.status = 'active'
|
||||||
subscription.expires_at = timezone.now() + timezone.timedelta(days=duration_days)
|
subscription.expires_at = timezone.now() + timezone.timedelta(days=duration_days)
|
||||||
subscription.save(update_fields=['status', 'expires_at'])
|
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
|
return subscription
|
||||||
|
|||||||
Reference in New Issue
Block a user