From e1d830f6a99eb27a805a1c67be14a483cd55ee2e Mon Sep 17 00:00:00 2001 From: AliReza Date: Tue, 11 Aug 2026 16:43:12 +0330 Subject: [PATCH] Initial commit --- .gitignore | 6 + README.md | 45 ++++ accounts/__init__.py | 0 accounts/admin.py | 27 ++ accounts/apps.py | 10 + accounts/authentication.py | 60 +++++ accounts/migrations/0001_initial.py | 62 +++++ accounts/migrations/__init__.py | 0 accounts/models.py | 67 +++++ accounts/schema.py | 22 ++ accounts/serializers.py | 68 +++++ accounts/tests.py | 3 + accounts/urls.py | 17 ++ accounts/views.py | 187 +++++++++++++ affiliate/__init__.py | 0 affiliate/admin.py | 21 ++ affiliate/apps.py | 6 + affiliate/migrations/0001_initial.py | 41 +++ affiliate/migrations/__init__.py | 0 affiliate/models.py | 39 +++ affiliate/serializers.py | 28 ++ affiliate/tests.py | 3 + affiliate/urls.py | 9 + affiliate/views.py | 92 +++++++ ai_tutor/__init__.py | 0 ai_tutor/admin.py | 25 ++ ai_tutor/apps.py | 6 + ai_tutor/migrations/0001_initial.py | 48 ++++ ai_tutor/migrations/__init__.py | 0 ai_tutor/models.py | 36 +++ ai_tutor/serializers.py | 39 +++ ai_tutor/tests.py | 3 + ai_tutor/urls.py | 9 + ai_tutor/views.py | 147 +++++++++++ config/__init__.py | 0 config/asgi.py | 16 ++ config/settings.py | 218 ++++++++++++++++ config/urls.py | 45 ++++ config/wsgi.py | 16 ++ content/__init__.py | 0 content/admin.py | 50 ++++ content/apps.py | 6 + content/migrations/0001_initial.py | 106 ++++++++ content/migrations/__init__.py | 0 content/models.py | 100 +++++++ content/serializers.py | 94 +++++++ content/tests.py | 3 + content/urls.py | 19 ++ content/views.py | 247 ++++++++++++++++++ discount/__init__.py | 0 discount/admin.py | 19 ++ discount/apps.py | 6 + discount/migrations/0001_initial.py | 36 +++ ...ion_unique_redemption_per_user_per_code.py | 17 ++ discount/migrations/__init__.py | 0 discount/models.py | 34 +++ discount/serializers.py | 18 ++ discount/tests.py | 3 + discount/urls.py | 8 + discount/views.py | 129 +++++++++ family/__init__.py | 0 family/admin.py | 33 +++ family/apps.py | 6 + family/migrations/0001_initial.py | 55 ++++ family/migrations/__init__.py | 0 family/models.py | 55 ++++ family/serializers.py | 51 ++++ family/tests.py | 3 + family/urls.py | 17 ++ family/views.py | 241 +++++++++++++++++ manage.py | 22 ++ notifications/__init__.py | 0 notifications/admin.py | 10 + notifications/apps.py | 6 + notifications/migrations/0001_initial.py | 37 +++ notifications/migrations/__init__.py | 0 notifications/models.py | 30 +++ notifications/serializers.py | 21 ++ notifications/services.py | 27 ++ notifications/tests.py | 3 + notifications/urls.py | 8 + notifications/views.py | 37 +++ payment/__init__.py | 0 payment/admin.py | 21 ++ payment/apps.py | 6 + payment/migrations/0001_initial.py | 46 ++++ payment/migrations/__init__.py | 0 payment/models.py | 38 +++ payment/permissions.py | 31 +++ payment/serializers.py | 46 ++++ payment/services.py | 98 +++++++ payment/tests.py | 3 + payment/urls.py | 9 + payment/views.py | 174 ++++++++++++ progress/__init__.py | 0 progress/admin.py | 30 +++ progress/apps.py | 6 + progress/migrations/0001_initial.py | 68 +++++ progress/migrations/__init__.py | 0 progress/models.py | 56 ++++ progress/serializers.py | 41 +++ progress/tests.py | 3 + progress/urls.py | 10 + progress/views.py | 109 ++++++++ requirements.txt | 3 + subscription/__init__.py | 0 subscription/admin.py | 14 + subscription/apps.py | 6 + subscription/migrations/0001_initial.py | 38 +++ .../migrations/0002_plan_duration_days.py | 18 ++ subscription/migrations/__init__.py | 0 subscription/models.py | 55 ++++ subscription/serializers.py | 33 +++ subscription/services.py | 23 ++ subscription/tests.py | 3 + subscription/urls.py | 9 + subscription/views.py | 115 ++++++++ support/__init__.py | 0 support/admin.py | 52 ++++ support/apps.py | 6 + support/migrations/0001_initial.py | 59 +++++ support/migrations/__init__.py | 0 support/models.py | 54 ++++ support/serializers.py | 45 ++++ support/tests.py | 3 + support/urls.py | 19 ++ support/views.py | 126 +++++++++ 127 files changed, 4454 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 accounts/__init__.py create mode 100644 accounts/admin.py create mode 100644 accounts/apps.py create mode 100644 accounts/authentication.py create mode 100644 accounts/migrations/0001_initial.py create mode 100644 accounts/migrations/__init__.py create mode 100644 accounts/models.py create mode 100644 accounts/schema.py create mode 100644 accounts/serializers.py create mode 100644 accounts/tests.py create mode 100644 accounts/urls.py create mode 100644 accounts/views.py create mode 100644 affiliate/__init__.py create mode 100644 affiliate/admin.py create mode 100644 affiliate/apps.py create mode 100644 affiliate/migrations/0001_initial.py create mode 100644 affiliate/migrations/__init__.py create mode 100644 affiliate/models.py create mode 100644 affiliate/serializers.py create mode 100644 affiliate/tests.py create mode 100644 affiliate/urls.py create mode 100644 affiliate/views.py create mode 100644 ai_tutor/__init__.py create mode 100644 ai_tutor/admin.py create mode 100644 ai_tutor/apps.py create mode 100644 ai_tutor/migrations/0001_initial.py create mode 100644 ai_tutor/migrations/__init__.py create mode 100644 ai_tutor/models.py create mode 100644 ai_tutor/serializers.py create mode 100644 ai_tutor/tests.py create mode 100644 ai_tutor/urls.py create mode 100644 ai_tutor/views.py create mode 100644 config/__init__.py create mode 100644 config/asgi.py create mode 100644 config/settings.py create mode 100644 config/urls.py create mode 100644 config/wsgi.py create mode 100644 content/__init__.py create mode 100644 content/admin.py create mode 100644 content/apps.py create mode 100644 content/migrations/0001_initial.py create mode 100644 content/migrations/__init__.py create mode 100644 content/models.py create mode 100644 content/serializers.py create mode 100644 content/tests.py create mode 100644 content/urls.py create mode 100644 content/views.py create mode 100644 discount/__init__.py create mode 100644 discount/admin.py create mode 100644 discount/apps.py create mode 100644 discount/migrations/0001_initial.py create mode 100644 discount/migrations/0002_redemption_unique_redemption_per_user_per_code.py create mode 100644 discount/migrations/__init__.py create mode 100644 discount/models.py create mode 100644 discount/serializers.py create mode 100644 discount/tests.py create mode 100644 discount/urls.py create mode 100644 discount/views.py create mode 100644 family/__init__.py create mode 100644 family/admin.py create mode 100644 family/apps.py create mode 100644 family/migrations/0001_initial.py create mode 100644 family/migrations/__init__.py create mode 100644 family/models.py create mode 100644 family/serializers.py create mode 100644 family/tests.py create mode 100644 family/urls.py create mode 100644 family/views.py create mode 100755 manage.py create mode 100644 notifications/__init__.py create mode 100644 notifications/admin.py create mode 100644 notifications/apps.py create mode 100644 notifications/migrations/0001_initial.py create mode 100644 notifications/migrations/__init__.py create mode 100644 notifications/models.py create mode 100644 notifications/serializers.py create mode 100644 notifications/services.py create mode 100644 notifications/tests.py create mode 100644 notifications/urls.py create mode 100644 notifications/views.py create mode 100644 payment/__init__.py create mode 100644 payment/admin.py create mode 100644 payment/apps.py create mode 100644 payment/migrations/0001_initial.py create mode 100644 payment/migrations/__init__.py create mode 100644 payment/models.py create mode 100644 payment/permissions.py create mode 100644 payment/serializers.py create mode 100644 payment/services.py create mode 100644 payment/tests.py create mode 100644 payment/urls.py create mode 100644 payment/views.py create mode 100644 progress/__init__.py create mode 100644 progress/admin.py create mode 100644 progress/apps.py create mode 100644 progress/migrations/0001_initial.py create mode 100644 progress/migrations/__init__.py create mode 100644 progress/models.py create mode 100644 progress/serializers.py create mode 100644 progress/tests.py create mode 100644 progress/urls.py create mode 100644 progress/views.py create mode 100644 requirements.txt create mode 100644 subscription/__init__.py create mode 100644 subscription/admin.py create mode 100644 subscription/apps.py create mode 100644 subscription/migrations/0001_initial.py create mode 100644 subscription/migrations/0002_plan_duration_days.py create mode 100644 subscription/migrations/__init__.py create mode 100644 subscription/models.py create mode 100644 subscription/serializers.py create mode 100644 subscription/services.py create mode 100644 subscription/tests.py create mode 100644 subscription/urls.py create mode 100644 subscription/views.py create mode 100644 support/__init__.py create mode 100644 support/admin.py create mode 100644 support/apps.py create mode 100644 support/migrations/0001_initial.py create mode 100644 support/migrations/__init__.py create mode 100644 support/models.py create mode 100644 support/serializers.py create mode 100644 support/tests.py create mode 100644 support/urls.py create mode 100644 support/views.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9cb98cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +venv/ +__pycache__/ +*.pyc +db.sqlite3 +.DS_Store +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..27ae74b --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# Tadrisino API — فاز ۱ (۴ از ۱۱ اپ) + +پیاده‌سازی اولیه‌ی ۴ اپ اول (از ۱۱ اپ طراحی‌شده): `accounts`, `content`, `progress`, `ai_tutor`. +بقیه‌ی اپ‌ها (subscription, payment, discount, affiliate, support, notifications, family) در فاز بعدی اضافه می‌شن. + +## اجرا + +```bash +python3 -m venv venv +source venv/bin/activate # ویندوز: venv\Scripts\activate +pip install -r requirements.txt +python manage.py migrate +python manage.py createsuperuser # اختیاری، برای /admin +python manage.py runserver +``` + +## تست با Swagger +مرورگر: **http://127.0.0.1:8000/api/docs/** +(Redoc هم هست: `/api/redoc/`، و پنل ادمین: `/admin/`) + +## جریان تست پیشنهادی +1. `POST /api/auth/session` با `{"type":"guest","deviceId":"d_test1"}` → یک `token` می‌گیرید. +2. توی Swagger روی دکمه‌ی **Authorize** بزنید و فقط خودِ `token` رو بدون کلمه‌ی Bearer وارد کنید. +3. حالا endpointهای نیازمند auth (مثل `/api/user/grade`, `/api/user/summary`, `/api/ai/ask`) کار می‌کنن. +4. برای تست لاگین با شماره: `POST /api/auth/otp/request` بزنید (کد OTP رو در پاسخ برمی‌گردونه چون SMS واقعی وصل نیست)، یا مستقیم کد **0000** رو با هر شماره‌ای در `/api/auth/login` امتحان کنید (فقط در حالت توسعه). +5. برای تست محتوا: از پنل `/admin/` چند `Grade → Unit → Node → Lesson → Question → Choice` بسازید (حتماً یک Choice رو `is_correct=True` کنید)، بعد `/api/home?userId=1&grade=1`، `/api/lessons/{id}/questions`، `/api/questions/{id}/answer?userId=1` رو تست کنید. + +## نکات مهم / اصلاحات نسبت به سند اولیه +- **مدل `Choice`** در سند شما فیلد `is_correct` نداشت (خودتون هم توی سند بهش اشاره کرده بودید که شاید سهواً جا افتاده). بدون این فیلد امکان پیاده‌سازی `POST /api/questions/{id}/answer` نبود، پس اضافه‌ش کردم (کامنت توضیح در `content/models.py`). +- احراز هویت: از یک **Bearer token امضاشده و stateless** (بدون کتابخونه‌ی JWT خارجی، با `django.core.signing`) استفاده شده که دقیقاً با فیلدهای مدل `Session` شما (که فقط `refresh_token` داره، نه access token) سازگاره. +- اپ `accounts.User` عمداً `AUTH_USER_MODEL` جنگو نیست؛ یک مدل بیزنسی مستقله، طبق طراحی خودتون. +- endpoint `POST /api/ai/ask` فعلاً پاسخ **stub** برمی‌گردونه (بدون اتصال به مدل واقعی هوش مصنوعی) — محل اتصال با کامنت مشخص شده (`ai_tutor/views.py` تابع `generate_tutor_answer`). +- endpoint کمکی که در سند نبود ولی برای تست‌پذیری اضافه شد: `POST /api/auth/otp/request` (گرفتن کد OTP بدون SMS واقعی). + +## ساختار +``` +config/ تنظیمات پروژه (settings, urls اصلی) +accounts/ Identity & Profile +content/ Curriculum +progress/ Progress & Gamification +ai_tutor/ AI Tutor +``` + +## اپ‌های باقی‌مونده (فاز بعدی) +subscription, payment, discount, affiliate, support, notifications, family diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..c349a5a --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,27 @@ +from django.contrib import admin + +from .models import OTPCode, Profile, Session, User + + +@admin.register(User) +class UserAdmin(admin.ModelAdmin): + list_display = ('id', 'name', 'phone', 'device_id', 'is_guest', 'grade_id', 'created_at') + search_fields = ('name', 'phone', 'device_id') + list_filter = ('is_guest',) + + +@admin.register(Profile) +class ProfileAdmin(admin.ModelAdmin): + list_display = ('id', 'user', 'birth_year', 'parent_phone') + + +@admin.register(Session) +class SessionAdmin(admin.ModelAdmin): + list_display = ('id', 'user', 'device_id', 'expires_at', 'revoked') + list_filter = ('revoked',) + + +@admin.register(OTPCode) +class OTPCodeAdmin(admin.ModelAdmin): + list_display = ('id', 'phone', 'code', 'expires_at', 'is_used') + list_filter = ('is_used',) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..506458f --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts' + + def ready(self): + # ثبت OpenApiAuthenticationExtension برای دکمه‌ی Authorize توی Swagger UI + from . import schema # noqa: F401 diff --git a/accounts/authentication.py b/accounts/authentication.py new file mode 100644 index 0000000..4328bfc --- /dev/null +++ b/accounts/authentication.py @@ -0,0 +1,60 @@ +from django.conf import settings +from django.core import signing +from rest_framework.authentication import BaseAuthentication +from rest_framework.exceptions import AuthenticationFailed + +from .models import User + +ACCESS_TOKEN_SALT = 'tadrisino-access-token' + + +def issue_access_token(user_id: int) -> str: + """ + ساخت access token امضاشده (stateless) — دیتابیس ذخیره نمی‌شه، فقط + امضا و expiry داره. با django.core.signing (بدون نیاز به کتابخونه‌ی JWT). + """ + signer = signing.TimestampSigner(salt=ACCESS_TOKEN_SALT) + return signer.sign(str(user_id)) + + +def decode_access_token(token: str) -> int: + signer = signing.TimestampSigner(salt=ACCESS_TOKEN_SALT) + try: + value = signer.unsign(token, max_age=settings.ACCESS_TOKEN_TTL_SECONDS) + except signing.SignatureExpired: + raise AuthenticationFailed('توکن منقضی شده است.') + except signing.BadSignature: + raise AuthenticationFailed('توکن نامعتبر است.') + return int(value) + + +class TadrisinoTokenAuthentication(BaseAuthentication): + """ + هدر مورد انتظار: Authorization: Bearer + اگه هدر نباشه، درخواست به‌صورت anonymous ادامه پیدا می‌کنه (None, None) + و View خودش تصمیم می‌گیره که آیا auth اجباریه (IsAuthenticated) یا نه. + """ + + keyword = 'Bearer' + + def authenticate(self, request): + auth_header = request.headers.get('Authorization', '') + if not auth_header: + return None + + parts = auth_header.split() + if len(parts) != 2 or parts[0] != self.keyword: + return None + + token = parts[1] + user_id = decode_access_token(token) + + try: + user = User.objects.get(pk=user_id) + except User.DoesNotExist: + raise AuthenticationFailed('کاربر پیدا نشد.') + + return (user, token) + + def authenticate_header(self, request): + return self.keyword diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..39486e1 --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,62 @@ +# Generated by Django 5.2.17 on 2026-08-10 11:37 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='OTPCode', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('phone', models.CharField(db_index=True, max_length=15)), + ('code', models.CharField(max_length=6)), + ('expires_at', models.DateTimeField()), + ('is_used', models.BooleanField(default=False)), + ], + ), + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('phone', models.CharField(blank=True, db_index=True, max_length=15, null=True, unique=True)), + ('device_id', models.CharField(blank=True, db_index=True, max_length=64, null=True)), + ('name', models.CharField(blank=True, max_length=100)), + ('avatar', models.CharField(blank=True, max_length=100)), + ('is_guest', models.BooleanField(default=True)), + ('grade_id', models.IntegerField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='Session', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('refresh_token', models.CharField(max_length=200, unique=True)), + ('device_id', models.CharField(blank=True, max_length=64)), + ('expires_at', models.DateTimeField()), + ('revoked', models.BooleanField(default=False)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='accounts.user')), + ], + ), + migrations.CreateModel( + name='Profile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('birth_year', models.PositiveSmallIntegerField(blank=True, null=True)), + ('parent_phone', models.CharField(blank=True, max_length=15)), + ('bio', models.CharField(blank=True, max_length=200)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to='accounts.user')), + ], + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/models.py b/accounts/models.py new file mode 100644 index 0000000..814954d --- /dev/null +++ b/accounts/models.py @@ -0,0 +1,67 @@ +from django.db import models + + +class User(models.Model): + """ + کاربر اصلی سیستم (مهمان یا ثبت‌نام‌شده با شماره). + توجه: این مدل، AUTH_USER_MODEL جنگو نیست؛ یک مدل بیزنسی مستقله که + با TadrisinoTokenAuthentication سفارشی احراز هویت می‌شه. + + grade_id فقط یک ارجاع سبک (بدون FK واقعی) به content.Grade هست؛ + این اپ عمداً هیچ‌وقت FK واقعی به مدل اپ دیگه نمی‌زنه. + """ + phone = models.CharField(max_length=15, unique=True, null=True, blank=True, db_index=True) + device_id = models.CharField(max_length=64, null=True, blank=True, db_index=True) + name = models.CharField(max_length=100, blank=True) + avatar = models.CharField(max_length=100, blank=True) + is_guest = models.BooleanField(default=True) + grade_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Grade + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return self.name or self.phone or f"guest-{self.pk}" + + @property + def is_authenticated(self): + # این مدل AUTH_USER_MODEL نیست؛ این property فقط برای سازگاری با + # rest_framework.permissions.IsAuthenticated لازمه. + return True + + +class Profile(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile') + birth_year = models.PositiveSmallIntegerField(null=True, blank=True) + parent_phone = models.CharField(max_length=15, blank=True) + bio = models.CharField(max_length=200, blank=True) + + def __str__(self): + return f"profile-{self.user_id}" + + +class Session(models.Model): + """ + نگهداری refresh_token هر ورود. توکن دسترسی (access token) در دیتابیس + ذخیره نمی‌شه؛ به‌صورت stateless امضا (signed) می‌شه — دقیقاً طبق فیلدهای + همین مدل که فقط refresh_token داره، نه access token. + """ + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sessions') + refresh_token = models.CharField(max_length=200, unique=True) + device_id = models.CharField(max_length=64, blank=True) + expires_at = models.DateTimeField() + revoked = models.BooleanField(default=False) + + def __str__(self): + return f"session-{self.pk}-user{self.user_id}" + + +class OTPCode(models.Model): + phone = models.CharField(max_length=15, db_index=True) + code = models.CharField(max_length=6) + expires_at = models.DateTimeField() + is_used = models.BooleanField(default=False) + + def __str__(self): + return f"otp-{self.phone}" diff --git a/accounts/schema.py b/accounts/schema.py new file mode 100644 index 0000000..1ee9b52 --- /dev/null +++ b/accounts/schema.py @@ -0,0 +1,22 @@ +from drf_spectacular.extensions import OpenApiAuthenticationExtension + + +class TadrisinoTokenAuthenticationScheme(OpenApiAuthenticationExtension): + """ + این کلاس به drf-spectacular می‌گه که TadrisinoTokenAuthentication یک + Bearer token هست، تا دکمه‌ی "Authorize" توی Swagger UI درست نمایش داده بشه. + """ + target_class = 'accounts.authentication.TadrisinoTokenAuthentication' + name = 'BearerAuth' + + def get_security_definition(self, auto_schema): + return { + 'type': 'http', + 'scheme': 'bearer', + 'bearerFormat': 'access_token', + 'description': ( + 'توکن دسترسی که از /api/auth/session یا /api/auth/login گرفته می‌شه. ' + 'در فیلد Value فقط خودِ توکن رو بذارید (بدون کلمه‌ی Bearer)؛ Swagger ' + 'خودش اضافه می‌کنه.' + ), + } diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..e3ccba1 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,68 @@ +from rest_framework import serializers + + +# ---- Request serializers ---- + +class GuestSessionRequestSerializer(serializers.Serializer): + type = serializers.ChoiceField(choices=['guest']) + deviceId = serializers.CharField(max_length=64) + + +class LoginRequestSerializer(serializers.Serializer): + phone = serializers.CharField(max_length=15) + otp = serializers.CharField(max_length=6) + + +class RefreshRequestSerializer(serializers.Serializer): + refreshToken = serializers.CharField() + + +class SetGradeRequestSerializer(serializers.Serializer): + gradeId = serializers.IntegerField() + + +class RequestOTPRequestSerializer(serializers.Serializer): + """اپ کمکی برای گرفتن OTP (در سند اصلی نبود، برای تست‌پذیری Swagger اضافه شد).""" + phone = serializers.CharField(max_length=15) + + +# ---- Response serializers ---- + +class GuestSessionResponseSerializer(serializers.Serializer): + userId = serializers.CharField() + token = serializers.CharField() + isNewUser = serializers.BooleanField() + isGuest = serializers.BooleanField() + + +class LoginResponseSerializer(serializers.Serializer): + userId = serializers.CharField() + token = serializers.CharField() + refreshToken = serializers.CharField() + + +class RefreshResponseSerializer(serializers.Serializer): + token = serializers.CharField() + expiresIn = serializers.IntegerField() + + +class GradeSerializer(serializers.Serializer): + id = serializers.IntegerField() + title = serializers.CharField() + icon = serializers.CharField() + color = serializers.CharField() + + +class GradesListResponseSerializer(serializers.Serializer): + grades = GradeSerializer(many=True) + + +class SetGradeResponseSerializer(serializers.Serializer): + success = serializers.BooleanField() + gradeId = serializers.IntegerField() + + +class RequestOTPResponseSerializer(serializers.Serializer): + success = serializers.BooleanField() + devOtp = serializers.CharField() + note = serializers.CharField() diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..577255b --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,17 @@ +from django.urls import path + +from .views import ( + GuestSessionView, + LoginView, + RefreshTokenView, + RequestOTPView, + SetUserGradeView, +) + +urlpatterns = [ + path('auth/session', GuestSessionView.as_view(), name='auth-session'), + path('auth/otp/request', RequestOTPView.as_view(), name='auth-otp-request'), + path('auth/login', LoginView.as_view(), name='auth-login'), + path('auth/refresh', RefreshTokenView.as_view(), name='auth-refresh'), + path('user/grade', SetUserGradeView.as_view(), name='user-grade'), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..f7d38b6 --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,187 @@ +import random +import uuid + +from django.conf import settings +from django.utils import timezone +from drf_spectacular.utils import extend_schema +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from .authentication import issue_access_token +from .models import OTPCode, Session, User +from .serializers import ( + GuestSessionRequestSerializer, + GuestSessionResponseSerializer, + LoginRequestSerializer, + LoginResponseSerializer, + RefreshRequestSerializer, + RefreshResponseSerializer, + RequestOTPRequestSerializer, + RequestOTPResponseSerializer, + SetGradeRequestSerializer, + SetGradeResponseSerializer, +) + + +class GuestSessionView(APIView): + """POST /api/auth/session — ایجاد سشن مهمان""" + + @extend_schema( + request=GuestSessionRequestSerializer, + responses=GuestSessionResponseSerializer, + tags=['Identity & Profile (accounts)'], + ) + def post(self, request): + serializer = GuestSessionRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + device_id = serializer.validated_data['deviceId'] + + user, is_new_user = User.objects.get_or_create( + device_id=device_id, + is_guest=True, + defaults={'name': ''}, + ) + + token = issue_access_token(user.id) + + data = { + 'userId': str(user.id), + 'token': token, + 'isNewUser': is_new_user, + 'isGuest': True, + } + return Response(GuestSessionResponseSerializer(data).data) + + +class RequestOTPView(APIView): + """ + POST /api/auth/otp/request — درخواست کد OTP (کمکی، برای تست از طریق Swagger) + در محیط توسعه، کد ساخته‌شده در پاسخ برگردونده می‌شه تا نیازی به SMS واقعی نباشه. + """ + + @extend_schema( + request=RequestOTPRequestSerializer, + responses=RequestOTPResponseSerializer, + tags=['Identity & Profile (accounts)'], + ) + def post(self, request): + serializer = RequestOTPRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + phone = serializer.validated_data['phone'] + + code = f'{random.randint(0, 9999):04d}' + OTPCode.objects.create( + phone=phone, + code=code, + expires_at=timezone.now() + timezone.timedelta(minutes=5), + ) + data = { + 'success': True, + 'devOtp': code, + 'note': f'در پروداکشن این کد پیامک می‌شه؛ برای تست همیشه {settings.DEV_MASTER_OTP} هم قبول می‌شه.', + } + return Response(RequestOTPResponseSerializer(data).data) + + +class LoginView(APIView): + """POST /api/auth/login — ورود با OTP""" + + @extend_schema( + request=LoginRequestSerializer, + responses=LoginResponseSerializer, + tags=['Identity & Profile (accounts)'], + ) + def post(self, request): + serializer = LoginRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + phone = serializer.validated_data['phone'] + otp = serializer.validated_data['otp'] + + valid = False + if otp == settings.DEV_MASTER_OTP: + valid = True + else: + otp_qs = OTPCode.objects.filter( + phone=phone, code=otp, is_used=False, expires_at__gte=timezone.now() + ).order_by('-id') + otp_obj = otp_qs.first() + if otp_obj: + otp_obj.is_used = True + otp_obj.save(update_fields=['is_used']) + valid = True + + if not valid: + return Response({'detail': 'کد OTP نامعتبر یا منقضی‌شده است.'}, status=status.HTTP_400_BAD_REQUEST) + + user, _ = User.objects.get_or_create( + phone=phone, + defaults={'is_guest': False, 'name': ''}, + ) + if user.is_guest: + user.is_guest = False + user.save(update_fields=['is_guest']) + + refresh_token = uuid.uuid4().hex + Session.objects.create( + user=user, + refresh_token=refresh_token, + device_id=request.data.get('deviceId', ''), + expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS), + ) + access_token = issue_access_token(user.id) + + data = { + 'userId': str(user.id), + 'token': access_token, + 'refreshToken': refresh_token, + } + return Response(LoginResponseSerializer(data).data) + + +class RefreshTokenView(APIView): + """POST /api/auth/refresh — تمدید توکن""" + + @extend_schema( + request=RefreshRequestSerializer, + responses=RefreshResponseSerializer, + tags=['Identity & Profile (accounts)'], + ) + def post(self, request): + serializer = RefreshRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + refresh_token = serializer.validated_data['refreshToken'] + + session = Session.objects.filter( + refresh_token=refresh_token, revoked=False, expires_at__gte=timezone.now() + ).first() + if not session: + return Response({'detail': 'رفرش‌توکن نامعتبر یا منقضی‌شده است.'}, status=status.HTTP_401_UNAUTHORIZED) + + access_token = issue_access_token(session.user_id) + data = {'token': access_token, 'expiresIn': settings.ACCESS_TOKEN_TTL_SECONDS} + return Response(RefreshResponseSerializer(data).data) + + +class SetUserGradeView(APIView): + """POST /api/user/grade — ثبت پایه‌ی کاربر (نیاز به احراز هویت)""" + + permission_classes = [IsAuthenticated] + + @extend_schema( + request=SetGradeRequestSerializer, + responses=SetGradeResponseSerializer, + tags=['Identity & Profile (accounts)'], + ) + def post(self, request): + serializer = SetGradeRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + grade_id = serializer.validated_data['gradeId'] + + user = request.user + user.grade_id = grade_id + user.save(update_fields=['grade_id']) + + data = {'success': True, 'gradeId': grade_id} + return Response(SetGradeResponseSerializer(data).data) diff --git a/affiliate/__init__.py b/affiliate/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/affiliate/admin.py b/affiliate/admin.py new file mode 100644 index 0000000..736f944 --- /dev/null +++ b/affiliate/admin.py @@ -0,0 +1,21 @@ +from django.contrib import admin + +from .models import AffiliateAccount, Conversion + + +class ConversionInline(admin.TabularInline): + model = Conversion + extra = 0 + + +@admin.register(AffiliateAccount) +class AffiliateAccountAdmin(admin.ModelAdmin): + list_display = ('id', 'user_id', 'referral_code', 'commission_rate', 'created_at') + search_fields = ('referral_code',) + inlines = [ConversionInline] + + +@admin.register(Conversion) +class ConversionAdmin(admin.ModelAdmin): + list_display = ('id', 'affiliate_account', 'referred_user_id', 'status', 'commission_amount', 'created_at') + list_filter = ('status',) diff --git a/affiliate/apps.py b/affiliate/apps.py new file mode 100644 index 0000000..b6eceab --- /dev/null +++ b/affiliate/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AffiliateConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'affiliate' diff --git a/affiliate/migrations/0001_initial.py b/affiliate/migrations/0001_initial.py new file mode 100644 index 0000000..a6e62b1 --- /dev/null +++ b/affiliate/migrations/0001_initial.py @@ -0,0 +1,41 @@ +# Generated by Django 6.1 on 2026-08-10 15:43 + +import affiliate.models +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='AffiliateAccount', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_id', models.IntegerField(db_index=True, unique=True)), + ('referral_code', models.CharField(default=affiliate.models.generate_referral_code, max_length=20, unique=True)), + ('commission_rate', models.FloatField(default=0.1)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='Conversion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('referred_user_id', models.IntegerField(db_index=True, unique=True)), + ('order_id', models.IntegerField(blank=True, null=True)), + ('status', models.CharField(choices=[('pending', 'در انتظار'), ('paid', 'پرداخت\u200cشده')], default='pending', max_length=10)), + ('commission_amount', models.PositiveIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('affiliate_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='conversions', to='affiliate.affiliateaccount')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/affiliate/migrations/__init__.py b/affiliate/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/affiliate/models.py b/affiliate/models.py new file mode 100644 index 0000000..74dfa2c --- /dev/null +++ b/affiliate/models.py @@ -0,0 +1,39 @@ +import secrets +import string + +from django.db import models + + +def generate_referral_code() -> str: + """کد ۸ کاراکتری حروف‌بزرگ/عدد، مثل SARA10 در سند نمونه.""" + alphabet = string.ascii_uppercase + string.digits + return ''.join(secrets.choice(alphabet) for _ in range(8)) + + +class AffiliateAccount(models.Model): + user_id = models.IntegerField(unique=True, db_index=True) # ارجاع سبک به accounts.User + referral_code = models.CharField(max_length=20, unique=True, default=generate_referral_code) + commission_rate = models.FloatField(default=0.1) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"affiliate-user{self.user_id}-{self.referral_code}" + + +class Conversion(models.Model): + STATUS_CHOICES = [('pending', 'در انتظار'), ('paid', 'پرداخت‌شده')] + + affiliate_account = models.ForeignKey( + AffiliateAccount, on_delete=models.CASCADE, related_name='conversions' + ) + referred_user_id = models.IntegerField(unique=True, db_index=True) # ارجاع سبک به accounts.User + order_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به payment.Order + status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending') + commission_amount = models.PositiveIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return f"conversion-{self.pk}-referred{self.referred_user_id}" diff --git a/affiliate/serializers.py b/affiliate/serializers.py new file mode 100644 index 0000000..6b8cb65 --- /dev/null +++ b/affiliate/serializers.py @@ -0,0 +1,28 @@ +from rest_framework import serializers + + +class AffiliateMeResponseSerializer(serializers.Serializer): + referralCode = serializers.CharField() + commissionRate = serializers.FloatField() + + +class TrackRequestSerializer(serializers.Serializer): + referralCode = serializers.CharField() + referredUserId = serializers.CharField() + + +class TrackResponseSerializer(serializers.Serializer): + success = serializers.BooleanField() + conversionId = serializers.CharField() + status = serializers.CharField() + + +class ConversionItemSerializer(serializers.Serializer): + id = serializers.CharField() + referredUserId = serializers.CharField() + status = serializers.CharField() + commissionAmount = serializers.IntegerField() + + +class ConversionListResponseSerializer(serializers.Serializer): + conversions = ConversionItemSerializer(many=True) diff --git a/affiliate/tests.py b/affiliate/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/affiliate/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/affiliate/urls.py b/affiliate/urls.py new file mode 100644 index 0000000..6fe6687 --- /dev/null +++ b/affiliate/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import AffiliateMeView, ConversionsListView, TrackConversionView + +urlpatterns = [ + path('affiliate/me', AffiliateMeView.as_view(), name='affiliate-me'), + path('affiliate/track', TrackConversionView.as_view(), name='affiliate-track'), + path('affiliate/conversions', ConversionsListView.as_view(), name='affiliate-conversions'), +] diff --git a/affiliate/views.py b/affiliate/views.py new file mode 100644 index 0000000..74bd76b --- /dev/null +++ b/affiliate/views.py @@ -0,0 +1,92 @@ +from django.shortcuts import get_object_or_404 +from drf_spectacular.utils import extend_schema +from rest_framework.exceptions import ValidationError +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import AffiliateAccount, Conversion +from .serializers import ( + AffiliateMeResponseSerializer, + ConversionListResponseSerializer, + TrackRequestSerializer, + TrackResponseSerializer, +) + + +class AffiliateMeView(APIView): + """GET /api/affiliate/me — حساب و کد ارجاع کاربر""" + + permission_classes = [IsAuthenticated] + + @extend_schema(responses=AffiliateMeResponseSerializer, tags=['Affiliate (affiliate)']) + def get(self, request): + account, _ = AffiliateAccount.objects.get_or_create(user_id=request.user.id) + + data = {'referralCode': account.referral_code, 'commissionRate': account.commission_rate} + return Response(AffiliateMeResponseSerializer(data).data) + + +class TrackConversionView(APIView): + """ + POST /api/affiliate/track — ثبت کلیک/ارجاع + + طبق سند، این اپ فقط با referralCode به AffiliateAccount وصل می‌شه + (نه توکن کاربر)، چون referredUserId خودش کاربر تازه‌ارجاع‌شده‌ست که + لزوماً صاحب حساب افیلیت نیست؛ پس اینجا permission پیش‌فرض (AllowAny) + نگه داشته شده، دقیقاً مثل ValidateDiscountView در اپ discount. + """ + + @extend_schema( + request=TrackRequestSerializer, responses=TrackResponseSerializer, tags=['Affiliate (affiliate)'] + ) + def post(self, request): + serializer = TrackRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + v = serializer.validated_data + + account = get_object_or_404(AffiliateAccount, referral_code=v['referralCode']) + referred_user_id = int(v['referredUserId']) + + # ⚠️ اصلاح امنیتی: قبلاً referredUserId بدون هیچ اعتبارسنجی‌ای + # مستقیم مصرف می‌شد؛ یعنی می‌شد برای یک کاربر ناموجود Conversion + # ساخت یا با فرستادن userId خودِ صاحب حساب affiliate، به خودش + # ارجاع داد (self-referral). حالا هر دو حالت رد می‌شن. + from accounts.models import User as AccountUser + + if not AccountUser.objects.filter(pk=referred_user_id).exists(): + raise ValidationError({'referredUserId': 'چنین کاربری وجود ندارد.'}) + + if referred_user_id == account.user_id: + raise ValidationError({'referredUserId': 'امکان ثبت ارجاع برای خودِ صاحب کد وجود ندارد.'}) + + conversion, _ = Conversion.objects.get_or_create( + referred_user_id=referred_user_id, + defaults={'affiliate_account': account}, + ) + + data = {'success': True, 'conversionId': str(conversion.pk), 'status': conversion.status} + return Response(TrackResponseSerializer(data).data) + + +class ConversionsListView(APIView): + """GET /api/affiliate/conversions — لیست تبدیل‌ها و کمیسیون""" + + permission_classes = [IsAuthenticated] + + @extend_schema(responses=ConversionListResponseSerializer, tags=['Affiliate (affiliate)']) + def get(self, request): + account = AffiliateAccount.objects.filter(user_id=request.user.id).first() + conversions = account.conversions.all() if account else Conversion.objects.none() + + items = [ + { + 'id': str(c.pk), + 'referredUserId': str(c.referred_user_id), + 'status': c.status, + 'commissionAmount': c.commission_amount, + } + for c in conversions + ] + data = {'conversions': items} + return Response(ConversionListResponseSerializer(data).data) diff --git a/ai_tutor/__init__.py b/ai_tutor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai_tutor/admin.py b/ai_tutor/admin.py new file mode 100644 index 0000000..372e8e2 --- /dev/null +++ b/ai_tutor/admin.py @@ -0,0 +1,25 @@ +from django.contrib import admin + +from .models import Conversation, Message, MessageFeedback + + +class MessageInline(admin.TabularInline): + model = Message + extra = 0 + + +@admin.register(Conversation) +class ConversationAdmin(admin.ModelAdmin): + list_display = ('id', 'user_id', 'lesson_id', 'grade_id', 'created_at') + inlines = [MessageInline] + + +@admin.register(Message) +class MessageAdmin(admin.ModelAdmin): + list_display = ('id', 'conversation', 'role', 'created_at') + list_filter = ('role',) + + +@admin.register(MessageFeedback) +class MessageFeedbackAdmin(admin.ModelAdmin): + list_display = ('id', 'message', 'is_helpful') diff --git a/ai_tutor/apps.py b/ai_tutor/apps.py new file mode 100644 index 0000000..d5baf6a --- /dev/null +++ b/ai_tutor/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AiTutorConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'ai_tutor' diff --git a/ai_tutor/migrations/0001_initial.py b/ai_tutor/migrations/0001_initial.py new file mode 100644 index 0000000..1c03549 --- /dev/null +++ b/ai_tutor/migrations/0001_initial.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.17 on 2026-08-10 11:37 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Conversation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_id', models.IntegerField(db_index=True)), + ('lesson_id', models.IntegerField(blank=True, null=True)), + ('chapter_id', models.IntegerField(blank=True, null=True)), + ('grade_id', models.IntegerField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='Message', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(choices=[('user', 'کاربر'), ('assistant', 'دستیار')], max_length=10)), + ('text', models.TextField()), + ('question_id', models.IntegerField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='ai_tutor.conversation')), + ], + options={ + 'ordering': ['created_at'], + }, + ), + migrations.CreateModel( + name='MessageFeedback', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_helpful', models.BooleanField()), + ('message', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='feedback', to='ai_tutor.message')), + ], + ), + ] diff --git a/ai_tutor/migrations/__init__.py b/ai_tutor/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai_tutor/models.py b/ai_tutor/models.py new file mode 100644 index 0000000..7768c22 --- /dev/null +++ b/ai_tutor/models.py @@ -0,0 +1,36 @@ +from django.db import models + + +class Conversation(models.Model): + user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User + lesson_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Lesson + chapter_id = models.IntegerField(null=True, blank=True) # شناسه‌ی فصل RAG (خارج از content/) + grade_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Grade + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"conv-{self.pk}-user{self.user_id}" + + +class Message(models.Model): + ROLE_CHOICES = [('user', 'کاربر'), ('assistant', 'دستیار')] + + conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name='messages') + role = models.CharField(max_length=10, choices=ROLE_CHOICES) + text = models.TextField() + question_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Question + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['created_at'] + + def __str__(self): + return f"msg-{self.pk}-{self.role}" + + +class MessageFeedback(models.Model): + message = models.OneToOneField(Message, on_delete=models.CASCADE, related_name='feedback') + is_helpful = models.BooleanField() + + def __str__(self): + return f"feedback-msg{self.message_id}" diff --git a/ai_tutor/serializers.py b/ai_tutor/serializers.py new file mode 100644 index 0000000..8d72553 --- /dev/null +++ b/ai_tutor/serializers.py @@ -0,0 +1,39 @@ +from rest_framework import serializers + + +class AskRequestSerializer(serializers.Serializer): + conversationId = serializers.CharField(required=False, allow_null=True, allow_blank=True) + lessonId = serializers.CharField(required=False, allow_null=True, allow_blank=True) + chapterId = serializers.CharField(required=False, allow_null=True, allow_blank=True) + gradeId = serializers.IntegerField(required=False, allow_null=True) + questionId = serializers.CharField(required=False, allow_null=True, allow_blank=True) + userQuestion = serializers.CharField() + stream = serializers.BooleanField(required=False, default=False) + + +class AskResponseSerializer(serializers.Serializer): + conversationId = serializers.CharField() + messageId = serializers.CharField() + answer = serializers.CharField() + suggestedFollowUps = serializers.ListField(child=serializers.CharField()) + remainingQuota = serializers.IntegerField() + + +class MessageSerializer(serializers.Serializer): + messageId = serializers.CharField() + role = serializers.CharField() + text = serializers.CharField() + + +class ConversationMessagesResponseSerializer(serializers.Serializer): + conversationId = serializers.CharField() + lessonId = serializers.CharField(allow_null=True) + messages = MessageSerializer(many=True) + + +class FeedbackRequestSerializer(serializers.Serializer): + isHelpful = serializers.BooleanField() + + +class FeedbackResponseSerializer(serializers.Serializer): + success = serializers.BooleanField() diff --git a/ai_tutor/tests.py b/ai_tutor/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/ai_tutor/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/ai_tutor/urls.py b/ai_tutor/urls.py new file mode 100644 index 0000000..db4a1db --- /dev/null +++ b/ai_tutor/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import AskAIView, ConversationMessagesView, MessageFeedbackView + +urlpatterns = [ + path('ai/ask', AskAIView.as_view(), name='ai-ask'), + path('ai/conversations//messages', ConversationMessagesView.as_view(), name='ai-conversation-messages'), + path('ai/messages//feedback', MessageFeedbackView.as_view(), name='ai-message-feedback'), +] diff --git a/ai_tutor/views.py b/ai_tutor/views.py new file mode 100644 index 0000000..63d42a8 --- /dev/null +++ b/ai_tutor/views.py @@ -0,0 +1,147 @@ +from django.db import transaction +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from drf_spectacular.utils import extend_schema +from rest_framework.exceptions import Throttled +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from accounts.models import User as AccountUser +from .models import Conversation, Message, MessageFeedback +from .serializers import ( + AskRequestSerializer, + AskResponseSerializer, + ConversationMessagesResponseSerializer, + FeedbackRequestSerializer, + FeedbackResponseSerializer, +) + +DAILY_AI_QUOTA = 15 + + +def generate_tutor_answer(user_question: str, grade_id, lesson_id) -> tuple[str, list[str]]: + """ + استاب پاسخ AI Tutor. اینجا محل اتصال به مدل واقعی (مثلاً Claude API از طریق + Anthropic SDK) هست؛ فعلاً برای اینکه بدون نیاز به کلید API هم بشه کل جریان + رو با Swagger تست کرد، یک پاسخ ساده و قطعی برمی‌گردونه. + """ + answer = ( + f'این یک پاسخ نمونه (stub) برای سوال «{user_question}» است. ' + 'برای اتصال به مدل واقعی هوش مصنوعی، این تابع را در ai_tutor/views.py ' + 'جایگزین کنید (مثلاً با فراخوانی Anthropic API).' + ) + follow_ups = ['یک مثال دیگر بزن', 'بیشتر توضیح بده'] + return answer, follow_ups + + +class AskAIView(APIView): + """POST /api/ai/ask — سوال از AI (استریم اختیاری؛ در این فاز استریم پیاده نشده)""" + + permission_classes = [IsAuthenticated] + + @extend_schema(request=AskRequestSerializer, responses=AskResponseSerializer, tags=['AI Tutor (ai_tutor)']) + def post(self, request): + serializer = AskRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + v = serializer.validated_data + user = request.user + + # ⚠️ اصلاح: چک و مصرفِ کوتای روزانه قبلاً race condition داشت — + # دو درخواست هم‌زمان از همون کاربر می‌تونستن هر دو شمارشِ «امروز» + # رو قبل از ساختن پیامِ خودشون بخونن و هر دو رد بشن، در نتیجه از + # سقف ۱۵ سوال روزانه عبور کنن. حالا با قفل‌کردن ردیف کاربر + # (select_for_update) در یک تراکنش اتمیک، چک و مصرفِ کوتا برای هر + # کاربر serialize می‌شه؛ درخواست‌های هم‌زمانِ همون کاربر منتظر آزاد + # شدن قفل می‌مونن و دیگه نمی‌تونن هم‌زمان از کنار چک رد بشن. + with transaction.atomic(): + AccountUser.objects.select_for_update().get(pk=user.id) + + today = timezone.localdate() + used_today = Message.objects.filter( + conversation__user_id=user.id, role='user', created_at__date=today + ).count() + if used_today >= DAILY_AI_QUOTA: + raise Throttled(detail='سقف روزانه‌ی سوال از هوش مصنوعی به پایان رسیده است.') + + conversation_id = v.get('conversationId') + if conversation_id: + conversation = get_object_or_404(Conversation, pk=conversation_id, user_id=user.id) + else: + conversation = Conversation.objects.create( + user_id=user.id, + lesson_id=v.get('lessonId') or None, + chapter_id=v.get('chapterId') or None, + grade_id=v.get('gradeId'), + ) + + Message.objects.create( + conversation=conversation, + role='user', + text=v['userQuestion'], + question_id=v.get('questionId') or None, + ) + + answer_text, follow_ups = generate_tutor_answer( + v['userQuestion'], v.get('gradeId'), v.get('lessonId') + ) + assistant_message = Message.objects.create( + conversation=conversation, + role='assistant', + text=answer_text, + question_id=v.get('questionId') or None, + ) + + used_today += 1 # همین سوالی که الان ثبت شد هم جزو مصرف امروزه + remaining_quota = max(DAILY_AI_QUOTA - used_today, 0) + + data = { + 'conversationId': str(conversation.id), + 'messageId': str(assistant_message.id), + 'answer': answer_text, + 'suggestedFollowUps': follow_ups, + 'remainingQuota': remaining_quota, + } + return Response(AskResponseSerializer(data).data) + + +class ConversationMessagesView(APIView): + """GET /api/ai/conversations/{id}/messages — تاریخچه‌ی مکالمه""" + + permission_classes = [IsAuthenticated] + + @extend_schema(responses=ConversationMessagesResponseSerializer, tags=['AI Tutor (ai_tutor)']) + def get(self, request, conversation_id): + conversation = get_object_or_404(Conversation, pk=conversation_id, user_id=request.user.id) + messages = conversation.messages.all() + + data = { + 'conversationId': str(conversation.id), + 'lessonId': str(conversation.lesson_id) if conversation.lesson_id else None, + 'messages': [ + {'messageId': str(m.id), 'role': m.role, 'text': m.text} for m in messages + ], + } + return Response(ConversationMessagesResponseSerializer(data).data) + + +class MessageFeedbackView(APIView): + """POST /api/ai/messages/{id}/feedback — فیدبک 👍/👎""" + + permission_classes = [IsAuthenticated] + + @extend_schema( + request=FeedbackRequestSerializer, + responses=FeedbackResponseSerializer, + tags=['AI Tutor (ai_tutor)'], + ) + def post(self, request, message_id): + message = get_object_or_404(Message, pk=message_id, conversation__user_id=request.user.id) + serializer = FeedbackRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + MessageFeedback.objects.update_or_create( + message=message, + defaults={'is_helpful': serializer.validated_data['isHelpful']}, + ) + return Response(FeedbackResponseSerializer({'success': True}).data) diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..ed7c431 --- /dev/null +++ b/config/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for config project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_asgi_application() diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..55041f5 --- /dev/null +++ b/config/settings.py @@ -0,0 +1,218 @@ +""" +Django settings for config project. + +Generated by 'django-admin startproject' using Django 5.2.17. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.2/ref/settings/ +""" + +import os +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-jn!s*25lz^+bjm101tzmlz8jlj70i0qka++*h-(!0s^^vk-0ss' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + # 3rd party + 'rest_framework', + 'drf_spectacular', + + # Tadrisino apps (فاز اول - ۴ از ۱۱ اپ) + 'accounts', + 'content', + 'progress', + 'ai_tutor', + + # Tadrisino apps (فاز دوم - بخش ۱ از ۳: subscription, payment, discount) + 'subscription', + 'payment', + 'discount', + + # Tadrisino apps (فاز دوم - بخش ۲: notifications) + 'notifications', + + # Tadrisino apps (فاز دوم - بخش ۳: affiliate) + 'affiliate', + + # Tadrisino apps (فاز دوم - بخش ۴: support) + 'support', + + # Tadrisino apps (فاز دوم - بخش ۵: family) + 'family', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'config.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'config.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/5.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.2/topics/i18n/ + +LANGUAGE_CODE = 'fa-ir' + +TIME_ZONE = 'Asia/Tehran' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.2/howto/static-files/ + +STATIC_URL = 'static/' + +# ----------------------------------------------------------------------- +# Tadrisino — تنظیمات پروژه +# ----------------------------------------------------------------------- + +# این پروژه از مدل User جنگو (auth) استفاده نمی‌کنه؛ طبق طراحی سینیورت +# accounts.User یک مدل مستقل (business model) هست، نه AUTH_USER_MODEL. +# احراز هویت با یک TokenAuthentication سفارشی (accounts/authentication.py) +# روی همین مدل accounts.User انجام می‌شه؛ نه سشن/کوکی جنگو. + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'accounts.authentication.TadrisinoTokenAuthentication', + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.AllowAny', + ], + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + 'DEFAULT_RENDERER_CLASSES': [ + 'rest_framework.renderers.JSONRenderer', + 'rest_framework.renderers.BrowsableAPIRenderer', + ], +} + +SPECTACULAR_SETTINGS = { + 'TITLE': 'Tadrisino API — فاز ۲ / بخش ۱ (+ Subscription, Payment, Discount)', + 'DESCRIPTION': ( + 'پیاده‌سازی ۸ اپ از ۱۱ اپ طراحی‌شده: ' + 'accounts, content, progress, ai_tutor (فاز ۱) + ' + 'subscription, payment, discount (فاز ۲ / بخش ۱) + ' + 'notifications (فاز ۲ / بخش ۲) + ' + 'affiliate (فاز ۲ / بخش ۳). ' + 'بقیه‌ی اپ‌ها (support, family) ' + 'در ادامه‌ی فاز ۲ اضافه می‌شن.' + ), + 'VERSION': '0.2.0-phase2-part3', + 'SERVE_INCLUDE_SCHEMA': False, + 'COMPONENT_SPLIT_REQUEST': True, +} + +# اعتبار توکن دسترسی (ثانیه) و رفرش‌توکن (روز) +ACCESS_TOKEN_TTL_SECONDS = 3600 +REFRESH_TOKEN_TTL_DAYS = 30 + +# در محیط توسعه/تست، این کد OTP همیشه معتبره تا بدون نیاز به SMS واقعی +# بشه لاگین رو با Swagger تست کرد. در پروداکشن حتماً حذف بشه. +DEV_MASTER_OTP = '0000' + +# ----------------------------------------------------------------------- +# Payment security +# ----------------------------------------------------------------------- +# POST /api/payment/orders طبق سند یک فراخوانی سرور-به-سرور از اپ +# Subscription‌ه (نه از کلاینت). در عمل subscription/views.py مستقیماً +# تابع payment.services.create_order رو import و صدا می‌زنه، پس این +# endpoint اصلاً لازم نیست از بیرون صدا زده بشه؛ اما چون در urls.py +# رجیستر و AllowAny‌ه، هر کلاینتی می‌تونه با یک userId دلخواه سفارش بسازه. +# برای بستن این حفره، درخواست باید هدر X-Internal-Key رو برابر همین مقدار +# بفرسته؛ در پروداکشن حتماً از env خونده بشه و یک مقدار تصادفی/طولانی باشه. +INTERNAL_SERVICE_KEY = os.environ.get('INTERNAL_SERVICE_KEY', 'dev-internal-service-key-change-me') + +# POST /api/payment/webhook قراره از درگاه پرداخت بیاد، نه از یک کلاینت +# دلخواه؛ بدون امضا هر کسی می‌تونه با یک orderId معتبر status=paid بفرسته +# و بدون پرداخت واقعی، اشتراک رو فعال کنه. درگاه‌های واقعی (زرین‌پال و غیره) +# امضای HMAC واقعی خودشون رو دارن؛ اینجا چون درگاه واقعی وصل نیست، به‌جای +# HMAC یک shared-secret ساده به اسم X-Webhook-Secret استفاده شده — همون +# سطح حفاظتی که در سند API برای این endpoint خواسته شده. برای اتصال واقعی +# این باید با بررسی امضای HMAC درگاه جایگزین بشه. +PAYMENT_WEBHOOK_SECRET = os.environ.get('PAYMENT_WEBHOOK_SECRET', 'dev-webhook-secret-change-me') + +# Default primary key field type +# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..f33c0ce --- /dev/null +++ b/config/urls.py @@ -0,0 +1,45 @@ +""" +مسیرهای اصلی پروژه‌ی Tadrisino — فاز ۱ (۴ از ۱۱ اپ) + +هر اپ زیر پیشوند /api/ خودش mount شده. مسیرهای هر endpoint دقیقاً طبق +سند «Endpointهای API» طراحی‌شده تا مقایسه با Swagger راحت باشه. +""" +from django.contrib import admin +from django.urls import include, path +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularRedocView, + SpectacularSwaggerView, +) + +urlpatterns = [ + path('admin/', admin.site.urls), + + # OpenAPI schema + Swagger UI + Redoc + path('api/schema/', SpectacularAPIView.as_view(), name='schema'), + path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), + path('api/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), + + # اپ‌های فاز ۱ + path('api/', include('accounts.urls')), + path('api/', include('content.urls')), + path('api/', include('progress.urls')), + path('api/', include('ai_tutor.urls')), + + # اپ‌های فاز ۲ / بخش ۱ (subscription, payment, discount) + path('api/', include('subscription.urls')), + path('api/', include('payment.urls')), + path('api/', include('discount.urls')), + + # اپ‌های فاز ۲ / بخش ۲ (notifications) + path('api/', include('notifications.urls')), + + # اپ‌های فاز ۲ / بخش ۳ (affiliate) + path('api/', include('affiliate.urls')), + + # اپ‌های فاز ۲ / بخش ۴ (support) + path('api/', include('support.urls')), + + # اپ‌های فاز ۲ / بخش ۵ (family) + path('api/', include('family.urls')), +] diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..e2fbd58 --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for config project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_wsgi_application() diff --git a/content/__init__.py b/content/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/content/admin.py b/content/admin.py new file mode 100644 index 0000000..7fdadef --- /dev/null +++ b/content/admin.py @@ -0,0 +1,50 @@ +from django.contrib import admin + +from .models import Choice, Grade, Lesson, Node, Question, Unit, UserAnswer, UserLessonCompletion + + +class ChoiceInline(admin.TabularInline): + model = Choice + extra = 1 + + +@admin.register(Grade) +class GradeAdmin(admin.ModelAdmin): + list_display = ('id', 'title', 'icon', 'color') + + +@admin.register(Unit) +class UnitAdmin(admin.ModelAdmin): + list_display = ('id', 'title', 'grade', 'order') + list_filter = ('grade',) + + +@admin.register(Node) +class NodeAdmin(admin.ModelAdmin): + list_display = ('id', 'title', 'unit', 'order') + list_filter = ('unit',) + + +@admin.register(Lesson) +class LessonAdmin(admin.ModelAdmin): + list_display = ('id', 'title', 'node') + list_filter = ('node',) + + +@admin.register(Question) +class QuestionAdmin(admin.ModelAdmin): + list_display = ('id', 'prompt', 'lesson', 'type') + list_filter = ('type', 'lesson') + inlines = [ChoiceInline] + + +@admin.register(UserLessonCompletion) +class UserLessonCompletionAdmin(admin.ModelAdmin): + list_display = ('id', 'user_id', 'lesson', 'correct_count', 'stars', 'xp_earned', 'coins_earned') + list_filter = ('lesson',) + + +@admin.register(UserAnswer) +class UserAnswerAdmin(admin.ModelAdmin): + list_display = ('id', 'user_id', 'question', 'choice', 'correct', 'xp_earned') + list_filter = ('correct',) diff --git a/content/apps.py b/content/apps.py new file mode 100644 index 0000000..273d169 --- /dev/null +++ b/content/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ContentConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'content' diff --git a/content/migrations/0001_initial.py b/content/migrations/0001_initial.py new file mode 100644 index 0000000..f816c47 --- /dev/null +++ b/content/migrations/0001_initial.py @@ -0,0 +1,106 @@ +# Generated by Django 5.2.17 on 2026-08-10 11:37 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Grade', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=50)), + ('icon', models.CharField(max_length=100)), + ('color', models.CharField(max_length=10)), + ], + ), + migrations.CreateModel( + name='Node', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=150)), + ('order', models.PositiveIntegerField(default=0)), + ], + options={ + 'ordering': ['order'], + }, + ), + migrations.CreateModel( + name='Lesson', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=150)), + ('intro_text', models.TextField()), + ('mascot_animation', models.CharField(default='explain', max_length=50)), + ('node', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lessons', to='content.node')), + ], + ), + migrations.CreateModel( + name='Question', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.CharField(choices=[('multiple_choice', 'چهارگزینه\u200cای'), ('count_objects', 'شمارش اشیاء')], max_length=30)), + ('prompt', models.TextField()), + ('asset_group', models.CharField(blank=True, max_length=100)), + ('lesson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='content.lesson')), + ], + ), + migrations.CreateModel( + name='Choice', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('value', models.CharField(max_length=100)), + ('is_correct', models.BooleanField(default=False)), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='choices', to='content.question')), + ], + ), + migrations.CreateModel( + name='Unit', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=100)), + ('order', models.PositiveIntegerField(default=0)), + ('grade', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='units', to='content.grade')), + ], + options={ + 'ordering': ['order'], + }, + ), + migrations.AddField( + model_name='node', + name='unit', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='nodes', to='content.unit'), + ), + migrations.CreateModel( + name='UserAnswer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_id', models.IntegerField(db_index=True)), + ('correct', models.BooleanField()), + ('xp_earned', models.PositiveIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('choice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='content.choice')), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='content.question')), + ], + ), + migrations.CreateModel( + name='UserLessonCompletion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_id', models.IntegerField(db_index=True)), + ('correct_count', models.PositiveIntegerField()), + ('stars', models.PositiveSmallIntegerField()), + ('xp_earned', models.PositiveIntegerField(default=0)), + ('coins_earned', models.PositiveIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('lesson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='completions', to='content.lesson')), + ], + ), + ] diff --git a/content/migrations/__init__.py b/content/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/content/models.py b/content/models.py new file mode 100644 index 0000000..302a7c8 --- /dev/null +++ b/content/models.py @@ -0,0 +1,100 @@ +from django.db import models + + +class Grade(models.Model): + title = models.CharField(max_length=50) + icon = models.CharField(max_length=100) + color = models.CharField(max_length=10) + + def __str__(self): + return self.title + + +class Unit(models.Model): + grade = models.ForeignKey(Grade, on_delete=models.CASCADE, related_name='units') + title = models.CharField(max_length=100) + order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ['order'] + + def __str__(self): + return self.title + + +class Node(models.Model): + unit = models.ForeignKey(Unit, on_delete=models.CASCADE, related_name='nodes') + title = models.CharField(max_length=150) + order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ['order'] + + def __str__(self): + return self.title + + +class Lesson(models.Model): + node = models.ForeignKey(Node, on_delete=models.CASCADE, related_name='lessons') + title = models.CharField(max_length=150) + intro_text = models.TextField() + mascot_animation = models.CharField(max_length=50, default='explain') + + def __str__(self): + return self.title + + +class Question(models.Model): + TYPE_CHOICES = [('multiple_choice', 'چهارگزینه‌ای'), ('count_objects', 'شمارش اشیاء')] + + lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE, related_name='questions') + type = models.CharField(max_length=30, choices=TYPE_CHOICES) + prompt = models.TextField() + asset_group = models.CharField(max_length=100, blank=True) + + def __str__(self): + return self.prompt[:50] + + +class Choice(models.Model): + """ + ⚠️ اصلاح نسبت به سند اولیه: دیاگرام معماری فقط فیلد `value` رو برای + Choice مشخص کرده بود و فیلد تشخیص گزینه‌ی درست (is_correct) رو نداشت + (این نکته توی سند models هم صراحتاً به‌عنوان یک ایراد/نقص علامت‌گذاری + شده بود: "یا این فیلد سهواً جا افتاده یا تشخیص جای دیگه‌ای انجام می‌شه"). + چون بدون این فیلد امکان پیاده‌سازی POST /api/questions/{id}/answer + (که باید correct را برگردونه) وجود نداره، این فیلد اینجا اضافه شده. + اگه منطق تشخیص پاسخ درست قراره جای دیگه‌ای (سرویس content-authoring) + انجام بشه، این فیلد رو می‌شه بعداً حذف/جابه‌جا کرد. + """ + question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name='choices') + value = models.CharField(max_length=100) + is_correct = models.BooleanField(default=False) # <-- اصلاح/افزوده شده + + def __str__(self): + return self.value + + +class UserLessonCompletion(models.Model): + user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User + lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE, related_name='completions') + correct_count = models.PositiveIntegerField() + stars = models.PositiveSmallIntegerField() + xp_earned = models.PositiveIntegerField(default=0) + coins_earned = models.PositiveIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"completion-user{self.user_id}-lesson{self.lesson_id}" + + +class UserAnswer(models.Model): + user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User + question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name='answers') + choice = models.ForeignKey(Choice, on_delete=models.CASCADE, related_name='answers') + correct = models.BooleanField() + xp_earned = models.PositiveIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"answer-user{self.user_id}-q{self.question_id}" diff --git a/content/serializers.py b/content/serializers.py new file mode 100644 index 0000000..76dc19a --- /dev/null +++ b/content/serializers.py @@ -0,0 +1,94 @@ +from rest_framework import serializers + +from .models import Choice, Grade, Lesson, Question + + +# ---- Grades ---- + +class GradeSerializer(serializers.ModelSerializer): + class Meta: + model = Grade + fields = ['id', 'title', 'icon', 'color'] + + +class GradesListResponseSerializer(serializers.Serializer): + grades = GradeSerializer(many=True) + + +# ---- Home (skill map + stats) ---- + +class NodeStatusSerializer(serializers.Serializer): + id = serializers.CharField() + title = serializers.CharField() + status = serializers.ChoiceField(choices=['locked', 'unlocked', 'completed']) + + +class UnitWithNodesSerializer(serializers.Serializer): + id = serializers.CharField() + title = serializers.CharField() + nodes = NodeStatusSerializer(many=True) + + +class StatsSerializer(serializers.Serializer): + progressPercent = serializers.FloatField() + xp = serializers.IntegerField() + coins = serializers.IntegerField() + + +class HomeResponseSerializer(serializers.Serializer): + units = UnitWithNodesSerializer(many=True) + stats = StatsSerializer() + + +# ---- Lesson intro ---- + +class LessonIntroResponseSerializer(serializers.Serializer): + lessonId = serializers.CharField() + title = serializers.CharField() + introText = serializers.CharField() + mascotAnimation = serializers.CharField() + + +# ---- Lesson questions ---- + +class ChoiceOptionSerializer(serializers.Serializer): + id = serializers.CharField() + value = serializers.CharField() + + +class QuestionSerializer(serializers.Serializer): + id = serializers.CharField() + type = serializers.CharField() + prompt = serializers.CharField() + options = ChoiceOptionSerializer(many=True) + + +class LessonQuestionsResponseSerializer(serializers.Serializer): + questions = QuestionSerializer(many=True) + + +# ---- Answer submission ---- + +class AnswerRequestSerializer(serializers.Serializer): + selectedOptionId = serializers.IntegerField() + + +class AnswerResponseSerializer(serializers.Serializer): + correct = serializers.BooleanField() + correctOptionId = serializers.CharField() + xpEarned = serializers.IntegerField() + heartsRemaining = serializers.IntegerField() + + +# ---- Lesson completion ---- + +class LessonCompleteRequestSerializer(serializers.Serializer): + correctCount = serializers.IntegerField() + totalQuestions = serializers.IntegerField() + + +class LessonCompleteResponseSerializer(serializers.Serializer): + stars = serializers.IntegerField() + xpEarned = serializers.IntegerField() + coinsEarned = serializers.IntegerField() + stats = StatsSerializer() diff --git a/content/tests.py b/content/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/content/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/content/urls.py b/content/urls.py new file mode 100644 index 0000000..5ba5359 --- /dev/null +++ b/content/urls.py @@ -0,0 +1,19 @@ +from django.urls import path + +from .views import ( + GradesListView, + HomeView, + LessonCompleteView, + LessonIntroView, + LessonQuestionsView, + QuestionAnswerView, +) + +urlpatterns = [ + path('grades', GradesListView.as_view(), name='grades-list'), + path('home', HomeView.as_view(), name='home'), + path('lessons//intro', LessonIntroView.as_view(), name='lesson-intro'), + path('lessons//questions', LessonQuestionsView.as_view(), name='lesson-questions'), + path('lessons//complete', LessonCompleteView.as_view(), name='lesson-complete'), + path('questions//answer', QuestionAnswerView.as_view(), name='question-answer'), +] diff --git a/content/views.py b/content/views.py new file mode 100644 index 0000000..9dd352a --- /dev/null +++ b/content/views.py @@ -0,0 +1,247 @@ +from django.db.models import Sum +from django.shortcuts import get_object_or_404 +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from progress.models import UserHearts, UserStats + +from .models import Choice, Grade, Lesson, Question, UserAnswer, UserLessonCompletion +from .serializers import ( + AnswerRequestSerializer, + AnswerResponseSerializer, + GradesListResponseSerializer, + HomeResponseSerializer, + LessonCompleteRequestSerializer, + LessonCompleteResponseSerializer, + LessonIntroResponseSerializer, + LessonQuestionsResponseSerializer, +) + + +class GradesListView(APIView): + """GET /api/grades — لیست پایه‌ها""" + + @extend_schema(responses=GradesListResponseSerializer, tags=['Curriculum (content)']) + def get(self, request): + grades = Grade.objects.all().order_by('id') + data = {'grades': [ + {'id': g.id, 'title': g.title, 'icon': g.icon, 'color': g.color} for g in grades + ]} + return Response(GradesListResponseSerializer(data).data) + + +def _node_status(node, user_id, is_first_unlocked_candidate): + lesson_ids = list(node.lessons.values_list('id', flat=True)) + if not lesson_ids: + return 'locked' + completed_count = UserLessonCompletion.objects.filter( + user_id=user_id, lesson_id__in=lesson_ids + ).values('lesson_id').distinct().count() + if completed_count >= len(lesson_ids): + return 'completed' + if completed_count > 0 or is_first_unlocked_candidate: + return 'unlocked' + return 'locked' + + +class HomeView(APIView): + """GET /api/home?userId&grade — نقشه‌ی مهارت‌ها + آمار (ادغام‌شده)""" + + @extend_schema( + parameters=[ + OpenApiParameter('userId', OpenApiTypes.INT, required=True), + OpenApiParameter('grade', OpenApiTypes.INT, required=False), + ], + responses=HomeResponseSerializer, + tags=['Curriculum (content)'], + ) + def get(self, request): + user_id = request.query_params.get('userId') + grade_id = request.query_params.get('grade') + if not user_id: + return Response({'detail': 'پارامتر userId الزامی است.'}, status=status.HTTP_400_BAD_REQUEST) + user_id = int(user_id) + + units_qs = Grade.objects.get(pk=grade_id).units.all() if grade_id else [] + + units_payload = [] + for unit in units_qs: + nodes = list(unit.nodes.all()) + node_payload = [] + unlock_next = True # اولین نودِ قفل‌نشده در هر واحد، باز می‌مونه + for node in nodes: + node_status = _node_status(node, user_id, unlock_next) + if node_status != 'completed': + unlock_next = False + node_payload.append({'id': str(node.id), 'title': node.title, 'status': node_status}) + units_payload.append({'id': str(unit.id), 'title': unit.title, 'nodes': node_payload}) + + stats = UserStats.objects.filter(user_id=user_id).first() + stats_payload = { + 'progressPercent': stats.progress_percent if stats else 0, + 'xp': stats.xp if stats else 0, + 'coins': stats.coins if stats else 0, + } + + data = {'units': units_payload, 'stats': stats_payload} + return Response(HomeResponseSerializer(data).data) + + +class LessonIntroView(APIView): + """GET /api/lessons/{id}/intro""" + + @extend_schema(responses=LessonIntroResponseSerializer, tags=['Curriculum (content)']) + def get(self, request, lesson_id): + lesson = get_object_or_404(Lesson, pk=lesson_id) + data = { + 'lessonId': str(lesson.id), + 'title': lesson.title, + 'introText': lesson.intro_text, + 'mascotAnimation': lesson.mascot_animation, + } + return Response(LessonIntroResponseSerializer(data).data) + + +class LessonQuestionsView(APIView): + """GET /api/lessons/{id}/questions""" + + @extend_schema(responses=LessonQuestionsResponseSerializer, tags=['Curriculum (content)']) + def get(self, request, lesson_id): + lesson = get_object_or_404(Lesson, pk=lesson_id) + questions = lesson.questions.prefetch_related('choices').all() + data = {'questions': [ + { + 'id': str(q.id), + 'type': q.type, + 'prompt': q.prompt, + 'options': [{'id': str(c.id), 'value': c.value} for c in q.choices.all()], + } + for q in questions + ]} + return Response(LessonQuestionsResponseSerializer(data).data) + + +class QuestionAnswerView(APIView): + """POST /api/questions/{id}/answer — ثبت پاسخ""" + + # ⚠️ اصلاح امنیتی: قبلاً userId از query/body کلاینت خونده می‌شد که به + # کلاینت اجازه می‌داد به‌جای هر کاربر دیگری پاسخ ثبت کنه (heart/xp کاربر + # دلخواه رو تغییر بده). حالا auth اجباریه و همیشه از request.user.id + # (که از توکن معتبر استخراج شده) استفاده می‌شه. + permission_classes = [IsAuthenticated] + + @extend_schema( + request=AnswerRequestSerializer, + responses=AnswerResponseSerializer, + tags=['Curriculum (content)'], + ) + def post(self, request, question_id): + question = get_object_or_404(Question, pk=question_id) + serializer = AnswerRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + selected_option_id = serializer.validated_data['selectedOptionId'] + + choice = get_object_or_404(Choice, pk=selected_option_id, question=question) + correct_choice = question.choices.filter(is_correct=True).first() + + user_id = request.user.id + xp_earned = 10 if choice.is_correct else 0 + + UserAnswer.objects.create( + user_id=user_id, + question=question, + choice=choice, + correct=choice.is_correct, + xp_earned=xp_earned, + ) + if not choice.is_correct: + hearts, _ = UserHearts.objects.get_or_create(user_id=user_id) + if hearts.current_hearts > 0: + hearts.current_hearts -= 1 + hearts.save(update_fields=['current_hearts']) + hearts_remaining = hearts.current_hearts + else: + hearts = UserHearts.objects.filter(user_id=user_id).first() + hearts_remaining = hearts.current_hearts if hearts else 5 + + data = { + 'correct': choice.is_correct, + 'correctOptionId': str(correct_choice.id) if correct_choice else '', + 'xpEarned': xp_earned, + 'heartsRemaining': hearts_remaining if hearts_remaining is not None else 5, + } + return Response(AnswerResponseSerializer(data).data) + + +class LessonCompleteView(APIView): + """POST /api/lessons/{id}/complete — اتمام درس + آمار جدید""" + + # ⚠️ اصلاح امنیتی: قبلاً userId از query/body کلاینت خونده می‌شد که به + # کلاینت اجازه می‌داد به‌جای هر کاربر دیگری xp/coins ثبت کنه. حالا auth + # اجباریه و همیشه از request.user.id استفاده می‌شه. + permission_classes = [IsAuthenticated] + + @extend_schema( + request=LessonCompleteRequestSerializer, + responses=LessonCompleteResponseSerializer, + tags=['Curriculum (content)'], + ) + def post(self, request, lesson_id): + lesson = get_object_or_404(Lesson, pk=lesson_id) + serializer = LessonCompleteRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + correct_count = serializer.validated_data['correctCount'] + total_questions = serializer.validated_data['totalQuestions'] or 1 + + ratio = correct_count / total_questions + stars = 3 if ratio >= 0.9 else 2 if ratio >= 0.6 else 1 if ratio > 0 else 0 + potential_xp = correct_count * 10 + potential_coins = stars * 5 + + user_id = request.user.id + + # ⚠️ اصلاح: تکمیل هر درس یک‌بارمصرفه — دفعات بعدیِ تکمیل همون درس + # نباید دوباره xp/coins به آمار کاربر اضافه کنه (قبلاً هر بار + # تکمیل، xp_earned/coins_earned رو بدون قید به stats اضافه می‌کرد). + is_first_completion = not UserLessonCompletion.objects.filter( + user_id=user_id, lesson=lesson + ).exists() + xp_earned = potential_xp if is_first_completion else 0 + coins_earned = potential_coins if is_first_completion else 0 + + UserLessonCompletion.objects.create( + user_id=user_id, + lesson=lesson, + correct_count=correct_count, + stars=stars, + xp_earned=xp_earned, + coins_earned=coins_earned, + ) + stats, _ = UserStats.objects.get_or_create(user_id=user_id) + stats.xp += xp_earned + stats.coins += coins_earned + + total_lessons = Lesson.objects.count() or 1 + completed_lessons = UserLessonCompletion.objects.filter( + user_id=user_id + ).values('lesson_id').distinct().count() + stats.progress_percent = round(min(completed_lessons / total_lessons, 1.0) * 100, 2) + stats.save(update_fields=['xp', 'coins', 'progress_percent']) + + stats_payload = { + 'progressPercent': stats.progress_percent, + 'xp': stats.xp, + 'coins': stats.coins, + } + + data = { + 'stars': stars, + 'xpEarned': xp_earned, + 'coinsEarned': coins_earned, + 'stats': stats_payload, + } + return Response(LessonCompleteResponseSerializer(data).data) diff --git a/discount/__init__.py b/discount/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/discount/admin.py b/discount/admin.py new file mode 100644 index 0000000..9a94f07 --- /dev/null +++ b/discount/admin.py @@ -0,0 +1,19 @@ +from django.contrib import admin + +from .models import DiscountCode, Redemption + + +class RedemptionInline(admin.TabularInline): + model = Redemption + extra = 0 + + +@admin.register(DiscountCode) +class DiscountCodeAdmin(admin.ModelAdmin): + list_display = ('id', 'code', 'type', 'value', 'max_uses', 'expires_at') + inlines = [RedemptionInline] + + +@admin.register(Redemption) +class RedemptionAdmin(admin.ModelAdmin): + list_display = ('id', 'discount_code', 'user_id', 'order_id', 'redeemed_at') diff --git a/discount/apps.py b/discount/apps.py new file mode 100644 index 0000000..0cae219 --- /dev/null +++ b/discount/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class DiscountConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'discount' diff --git a/discount/migrations/0001_initial.py b/discount/migrations/0001_initial.py new file mode 100644 index 0000000..cf1b7da --- /dev/null +++ b/discount/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2.17 on 2026-08-10 12:00 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='DiscountCode', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(max_length=30, unique=True)), + ('type', models.CharField(choices=[('percent', 'درصدی'), ('fixed', 'مبلغ ثابت')], max_length=10)), + ('value', models.PositiveIntegerField()), + ('max_uses', models.PositiveIntegerField(default=1)), + ('expires_at', models.DateTimeField(blank=True, null=True)), + ], + ), + migrations.CreateModel( + name='Redemption', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_id', models.IntegerField(db_index=True)), + ('order_id', models.IntegerField()), + ('redeemed_at', models.DateTimeField(auto_now_add=True)), + ('discount_code', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='redemptions', to='discount.discountcode')), + ], + ), + ] diff --git a/discount/migrations/0002_redemption_unique_redemption_per_user_per_code.py b/discount/migrations/0002_redemption_unique_redemption_per_user_per_code.py new file mode 100644 index 0000000..a789224 --- /dev/null +++ b/discount/migrations/0002_redemption_unique_redemption_per_user_per_code.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.17 on 2026-08-10 17:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('discount', '0001_initial'), + ] + + operations = [ + migrations.AddConstraint( + model_name='redemption', + constraint=models.UniqueConstraint(fields=('discount_code', 'user_id'), name='unique_redemption_per_user_per_code'), + ), + ] diff --git a/discount/migrations/__init__.py b/discount/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/discount/models.py b/discount/models.py new file mode 100644 index 0000000..6c5ab0b --- /dev/null +++ b/discount/models.py @@ -0,0 +1,34 @@ +from django.db import models + + +class DiscountCode(models.Model): + TYPE_CHOICES = [('percent', 'درصدی'), ('fixed', 'مبلغ ثابت')] + + code = models.CharField(max_length=30, unique=True) + type = models.CharField(max_length=10, choices=TYPE_CHOICES) + value = models.PositiveIntegerField() + max_uses = models.PositiveIntegerField(default=1) + expires_at = models.DateTimeField(null=True, blank=True) + + def __str__(self): + return self.code + + +class Redemption(models.Model): + discount_code = models.ForeignKey(DiscountCode, on_delete=models.CASCADE, related_name='redemptions') + user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User + order_id = models.IntegerField() # ارجاع سبک به payment.Order + redeemed_at = models.DateTimeField(auto_now_add=True) + + class Meta: + # هر کاربر برای یک کد تخفیف حداکثر یک‌بار می‌تونه redeem کنه؛ + # قبلاً هیچ محدودیتی در سطح دیتابیس نبود و همراه با نبود چک max_uses + # در RedeemDiscountView، امکان استفاده‌ی نامحدود از یک کد وجود داشت. + constraints = [ + models.UniqueConstraint( + fields=['discount_code', 'user_id'], name='unique_redemption_per_user_per_code' + ) + ] + + def __str__(self): + return f"redemption-{self.discount_code_id}-order{self.order_id}" diff --git a/discount/serializers.py b/discount/serializers.py new file mode 100644 index 0000000..1aacfcb --- /dev/null +++ b/discount/serializers.py @@ -0,0 +1,18 @@ +from rest_framework import serializers + + +class ValidateResponseSerializer(serializers.Serializer): + valid = serializers.BooleanField() + code = serializers.CharField(required=False) + type = serializers.CharField(required=False) + value = serializers.IntegerField(required=False) + + +class RedeemRequestSerializer(serializers.Serializer): + code = serializers.CharField() + orderId = serializers.CharField() + + +class RedeemResponseSerializer(serializers.Serializer): + success = serializers.BooleanField() + discountAmount = serializers.IntegerField() diff --git a/discount/tests.py b/discount/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/discount/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/discount/urls.py b/discount/urls.py new file mode 100644 index 0000000..6a7f78d --- /dev/null +++ b/discount/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from .views import RedeemDiscountView, ValidateDiscountView + +urlpatterns = [ + path('discount/validate', ValidateDiscountView.as_view(), name='discount-validate'), + path('discount/redeem', RedeemDiscountView.as_view(), name='discount-redeem'), +] diff --git a/discount/views.py b/discount/views.py new file mode 100644 index 0000000..a8d5543 --- /dev/null +++ b/discount/views.py @@ -0,0 +1,129 @@ +from django.db import IntegrityError, transaction +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework.exceptions import ValidationError +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import DiscountCode, Redemption +from .serializers import ( + RedeemRequestSerializer, + RedeemResponseSerializer, + ValidateResponseSerializer, +) + + +def _is_usable(discount: DiscountCode) -> bool: + return bool( + discount.redemptions.count() < discount.max_uses + and (discount.expires_at is None or discount.expires_at >= timezone.now()) + ) + + +class ValidateDiscountView(APIView): + """GET /api/discount/validate?code= — اعتبارسنجی کد پیش از خرید""" + + @extend_schema( + parameters=[OpenApiParameter('code', OpenApiTypes.STR, required=True)], + responses=ValidateResponseSerializer, + tags=['Discount (discount)'], + ) + def get(self, request): + code = request.query_params.get('code', '') + discount = DiscountCode.objects.filter(code=code).first() + + if not discount or not _is_usable(discount): + return Response(ValidateResponseSerializer({'valid': False}).data) + + data = {'valid': True, 'code': discount.code, 'type': discount.type, 'value': discount.value} + return Response(ValidateResponseSerializer(data).data) + + +class RedeemDiscountView(APIView): + """ + POST /api/discount/redeem — ثبت مصرف کد هنگام خرید + + قبلاً فقط ValidateDiscountView چک max_uses/expires_at رو انجام می‌داد؛ + خود Redeem هیچ‌کدوم از این محدودیت‌ها رو دوباره چک نمی‌کرد و بین + validate و redeem هم race condition ممکن بود (دو درخواست هم‌زمان هر دو + از سد شمارش رد بشن). اینجا: + - همون شرط‌های usable بودن (max_uses/expires_at) دوباره در لحظه‌ی + redeem چک می‌شه، نه فقط در validate. + - با select_for_update روی DiscountCode، شمارش و ساخت Redemption در + یک تراکنش اتمیک و با قفل ردیف انجام می‌شه تا از over-redemption + هم‌زمان جلوگیری بشه. + - هر کاربر برای یک کد، حداکثر یک‌بار می‌تونه redeem کنه (unique + constraint روی discount_code+user_id در سطح مدل/migration). + + طبق سند، این اپ هیچ محاسبه‌ی مبلغی رو دوباره خودش «ذخیره» نمی‌کنه؛ اما + برای پر کردن discountAmount در پاسخ، باید مبلغ سفارش رو از یک جایی + بخونه. چون هیچ اپ دیگه‌ای به این اپ ارجاع نمی‌ده (نه برعکس)، اینجا فقط + یک خواندن با id از payment.models.Order انجام می‌شه — دقیقاً همون + الگویی که progress/views.py برای خوندن نام کاربر از accounts.models.User + استفاده کرده (import مستقیم + فیلتر با id، نه FK واقعی و نه join). + """ + + permission_classes = [IsAuthenticated] + + @extend_schema(request=RedeemRequestSerializer, responses=RedeemResponseSerializer, + tags=['Discount (discount)']) + def post(self, request): + serializer = RedeemRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + v = serializer.validated_data + order_id = int(v['orderId']) + + from payment.models import Order + # ⚠️ اصلاح امنیتی: قبلاً مالکیتِ سفارش چک نمی‌شد، پس هر کاربر + # لاگین‌کرده‌ای می‌تونست با حدس‌زدن یک orderId، کد تخفیف رو روی + # سفارشِ کاربر دیگری اعمال کنه. حالا سفارش باید متعلق به همون + # کاربر احرازهویت‌شده باشه. + order = get_object_or_404(Order, pk=order_id, user_id=request.user.id) + + try: + with transaction.atomic(): + discount = get_object_or_404( + DiscountCode.objects.select_for_update(), code=v['code'] + ) + + if not _is_usable(discount): + raise ValidationError({'code': 'این کد دیگر معتبر نیست یا سقف استفاده‌اش پر شده.'}) + + if discount.redemptions.filter(user_id=request.user.id).exists(): + raise ValidationError({'code': 'شما قبلاً این کد را استفاده کرده‌اید.'}) + + # فقط سفارش‌های در انتظار پرداخت می‌تونن تخفیف بگیرن؛ سفارش + # پرداخت‌شده/ناموفق نباید عقب‌گرد تغییر کنه. + if order.status != 'pending': + raise ValidationError({'code': 'این سفارش دیگر قابل اعمال تخفیف نیست.'}) + + order_locked = Order.objects.select_for_update().get(pk=order.id) + + if discount.type == 'percent': + discount_amount = order_locked.amount * discount.value // 100 + else: + discount_amount = min(discount.value, order_locked.amount) + + Redemption.objects.create( + discount_code=discount, + user_id=request.user.id, + order_id=order_id, + ) + + # ⚠️ اصلاح: قبلاً discountAmount فقط محاسبه و در پاسخ + # برگردونده می‌شد ولی هیچ‌وقت روی خودِ Order اعمال نمی‌شد، + # یعنی جریان واقعی پرداخت مبلغ تخفیف‌نخورده رو دریافت می‌کرد. + # حالا مبلغ سفارش واقعاً کم می‌شه تا مرحله‌ی پرداخت همین + # مبلغ تخفیف‌خورده رو ببینه. + order_locked.amount = max(order_locked.amount - discount_amount, 0) + order_locked.save(update_fields=['amount']) + except IntegrityError: + # fallback در برابر race condition روی unique constraint، حتی + # اگه به هر دلیلی قفل select_for_update رد بشه. + raise ValidationError({'code': 'این کد دیگر معتبر نیست یا سقف استفاده‌اش پر شده.'}) + + data = {'success': True, 'discountAmount': discount_amount} + return Response(RedeemResponseSerializer(data).data) diff --git a/family/__init__.py b/family/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/family/admin.py b/family/admin.py new file mode 100644 index 0000000..222112c --- /dev/null +++ b/family/admin.py @@ -0,0 +1,33 @@ +from django.contrib import admin + +from .models import FamilyLink, ParentalSettings, WeeklyReportLog + + +class ParentalSettingsInline(admin.StackedInline): + model = ParentalSettings + extra = 0 + + +class WeeklyReportLogInline(admin.TabularInline): + model = WeeklyReportLog + extra = 0 + readonly_fields = ('sent_at',) + + +@admin.register(FamilyLink) +class FamilyLinkAdmin(admin.ModelAdmin): + list_display = ('id', 'parent_user_id', 'child_user_id', 'status', 'invite_code', 'invited_at', 'accepted_at') + list_filter = ('status',) + search_fields = ('invite_code', 'child_phone') + inlines = [ParentalSettingsInline, WeeklyReportLogInline] + + +@admin.register(ParentalSettings) +class ParentalSettingsAdmin(admin.ModelAdmin): + list_display = ('id', 'family_link', 'daily_limit_minutes', 'quiet_hours_start', 'quiet_hours_end') + + +@admin.register(WeeklyReportLog) +class WeeklyReportLogAdmin(admin.ModelAdmin): + list_display = ('id', 'family_link', 'week_start', 'sent_at', 'opened_at') + list_filter = ('week_start',) diff --git a/family/apps.py b/family/apps.py new file mode 100644 index 0000000..7bf23c4 --- /dev/null +++ b/family/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class FamilyConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'family' diff --git a/family/migrations/0001_initial.py b/family/migrations/0001_initial.py new file mode 100644 index 0000000..8c22767 --- /dev/null +++ b/family/migrations/0001_initial.py @@ -0,0 +1,55 @@ +# Generated by Django 5.2.17 on 2026-08-10 12:00 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='FamilyLink', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('parent_user_id', models.IntegerField(db_index=True)), + ('child_user_id', models.IntegerField(blank=True, db_index=True, null=True)), + ('child_phone', models.CharField(blank=True, max_length=15)), + ('invite_code', models.CharField(max_length=10, unique=True)), + ('status', models.CharField(choices=[('pending', 'در انتظار'), ('accepted', 'پذیرفته‌شده')], default='pending', max_length=10)), + ('invited_at', models.DateTimeField(auto_now_add=True)), + ('accepted_at', models.DateTimeField(blank=True, null=True)), + ], + options={ + 'ordering': ['-invited_at'], + }, + ), + migrations.CreateModel( + name='ParentalSettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('daily_limit_minutes', models.PositiveIntegerField(default=60)), + ('quiet_hours_start', models.TimeField(blank=True, null=True)), + ('quiet_hours_end', models.TimeField(blank=True, null=True)), + ('family_link', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='settings', to='family.familylink')), + ], + ), + migrations.CreateModel( + name='WeeklyReportLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('week_start', models.DateField()), + ('sent_at', models.DateTimeField(blank=True, null=True)), + ('opened_at', models.DateTimeField(blank=True, null=True)), + ('family_link', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='weekly_reports', to='family.familylink')), + ], + options={ + 'ordering': ['-week_start'], + 'unique_together': {('family_link', 'week_start')}, + }, + ), + ] diff --git a/family/migrations/__init__.py b/family/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/family/models.py b/family/models.py new file mode 100644 index 0000000..5cb81a9 --- /dev/null +++ b/family/models.py @@ -0,0 +1,55 @@ +from django.db import models + + +class FamilyLink(models.Model): + """ + اتصال حساب والد به یک فرزند. طبق سند معماری، این اپ فقط به accounts.User + ارجاع سبک (parent_user_id / child_user_id) می‌ده، نه FK واقعی. + + ⚠️ افزوده نسبت به سند اولیه: سند models فقط parent_user_id/child_user_id + را مشخص کرده بود، ولی برای پیاده‌سازی واقعی جریان «دعوت با کد» (طبق سند + Endpointها: POST /api/family/link/invite و /api/family/link/accept) باید + خود کد دعوت و شماره‌ی فرزندی که هنوز ثبت‌نام نکرده جایی نگه داشته بشه؛ + دقیقاً همون الگویی که accounts.OTPCode برای نگه‌داشتن کد یک‌بارمصرف + استفاده می‌کنه. اگه این دو فیلد قرار بود جای دیگه‌ای (مثلاً یک مدل + Invite جدا) نگه داشته بشن، می‌شه بعداً جابه‌جا کرد. + """ + STATUS_CHOICES = [('pending', 'در انتظار'), ('accepted', 'پذیرفته‌شده')] + + parent_user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User + child_user_id = models.IntegerField(null=True, blank=True, db_index=True) # ارجاع سبک به accounts.User + child_phone = models.CharField(max_length=15, blank=True) # <-- افزوده شده + invite_code = models.CharField(max_length=10, unique=True) # <-- افزوده شده + status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending') + invited_at = models.DateTimeField(auto_now_add=True) + accepted_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ['-invited_at'] + + def __str__(self): + return f"famlink-{self.pk}-parent{self.parent_user_id}-child{self.child_user_id or '?'}" + + +class ParentalSettings(models.Model): + family_link = models.OneToOneField(FamilyLink, on_delete=models.CASCADE, related_name='settings') + daily_limit_minutes = models.PositiveIntegerField(default=60) + quiet_hours_start = models.TimeField(null=True, blank=True) + quiet_hours_end = models.TimeField(null=True, blank=True) + + def __str__(self): + return f"settings-famlink{self.family_link_id}" + + +class WeeklyReportLog(models.Model): + family_link = models.ForeignKey(FamilyLink, on_delete=models.CASCADE, related_name='weekly_reports') + week_start = models.DateField() + sent_at = models.DateTimeField(null=True, blank=True) + opened_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ['-week_start'] + unique_together = [('family_link', 'week_start')] + + def __str__(self): + return f"weekly-famlink{self.family_link_id}-{self.week_start}" diff --git a/family/serializers.py b/family/serializers.py new file mode 100644 index 0000000..9c8e8d9 --- /dev/null +++ b/family/serializers.py @@ -0,0 +1,51 @@ +from rest_framework import serializers + + +class FamilyLinkInviteRequestSerializer(serializers.Serializer): + childPhone = serializers.CharField(max_length=15) + + +class FamilyLinkInviteResponseSerializer(serializers.Serializer): + familyLinkId = serializers.CharField() + inviteCode = serializers.CharField() + status = serializers.CharField() + + +class FamilyLinkAcceptRequestSerializer(serializers.Serializer): + inviteCode = serializers.CharField(max_length=10) + + +class FamilyLinkAcceptResponseSerializer(serializers.Serializer): + familyLinkId = serializers.CharField() + status = serializers.CharField() + acceptedAt = serializers.DateTimeField(allow_null=True) + + +class FamilyLinkStatusResponseSerializer(serializers.Serializer): + familyLinkId = serializers.CharField() + status = serializers.CharField() + childName = serializers.CharField(allow_blank=True) + + +class FamilySettingsResponseSerializer(serializers.Serializer): + dailyLimitMinutes = serializers.IntegerField() + quietHoursStart = serializers.TimeField(allow_null=True) + quietHoursEnd = serializers.TimeField(allow_null=True) + + +class FamilySettingsUpdateRequestSerializer(serializers.Serializer): + dailyLimitMinutes = serializers.IntegerField(required=False, min_value=0) + quietHoursStart = serializers.TimeField(required=False, allow_null=True) + quietHoursEnd = serializers.TimeField(required=False, allow_null=True) + + +class FamilySettingsUpdateResponseSerializer(serializers.Serializer): + success = serializers.BooleanField() + + +class FamilyWeeklyReportResponseSerializer(serializers.Serializer): + weekStart = serializers.DateField() + xpEarned = serializers.IntegerField() + lessonsCompleted = serializers.IntegerField() + currentStreak = serializers.IntegerField() + openedAt = serializers.DateTimeField(allow_null=True) diff --git a/family/tests.py b/family/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/family/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/family/urls.py b/family/urls.py new file mode 100644 index 0000000..d46ca4a --- /dev/null +++ b/family/urls.py @@ -0,0 +1,17 @@ +from django.urls import path + +from .views import ( + FamilyLinkAcceptView, + FamilyLinkInviteView, + FamilyLinkStatusView, + FamilySettingsView, + FamilyWeeklyReportView, +) + +urlpatterns = [ + path('family/link/invite', FamilyLinkInviteView.as_view(), name='family-link-invite'), + path('family/link/accept', FamilyLinkAcceptView.as_view(), name='family-link-accept'), + path('family/link', FamilyLinkStatusView.as_view(), name='family-link-status'), + path('family/settings', FamilySettingsView.as_view(), name='family-settings'), + path('family/weekly-report', FamilyWeeklyReportView.as_view(), name='family-weekly-report'), +] diff --git a/family/views.py b/family/views.py new file mode 100644 index 0000000..74cb343 --- /dev/null +++ b/family/views.py @@ -0,0 +1,241 @@ +from datetime import timedelta + +from django.db.models import Q, Sum +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from django.utils.crypto import get_random_string +from drf_spectacular.utils import extend_schema +from rest_framework.exceptions import NotFound, PermissionDenied +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import FamilyLink, ParentalSettings, WeeklyReportLog +from .serializers import ( + FamilyLinkAcceptRequestSerializer, + FamilyLinkAcceptResponseSerializer, + FamilyLinkInviteRequestSerializer, + FamilyLinkInviteResponseSerializer, + FamilyLinkStatusResponseSerializer, + FamilySettingsResponseSerializer, + FamilySettingsUpdateRequestSerializer, + FamilySettingsUpdateResponseSerializer, + FamilyWeeklyReportResponseSerializer, +) + + +def _week_start(dt): + """دوشنبه‌ی هفته‌ی جاری (ISO weekday) برای dt داده‌شده.""" + return (dt - timedelta(days=dt.weekday())).date() + + +class FamilyLinkInviteView(APIView): + """POST /api/family/link/invite — دعوت فرزند توسط والد (تولید کد اتصال)""" + + permission_classes = [IsAuthenticated] + + @extend_schema( + request=FamilyLinkInviteRequestSerializer, + responses=FamilyLinkInviteResponseSerializer, + tags=['Family (family)'], + ) + def post(self, request): + serializer = FamilyLinkInviteRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + v = serializer.validated_data + + # اگه دعوت pending برای همین شماره از قبل هست، به‌جای رکورد تکراری + # (مثل الگوی idempotency در payment/services.py) همون رو برمی‌گردونیم. + link = FamilyLink.objects.filter( + parent_user_id=request.user.id, child_phone=v['childPhone'], status='pending' + ).first() + + if not link: + code = get_random_string(6, allowed_chars='0123456789') + while FamilyLink.objects.filter(invite_code=code).exists(): + code = get_random_string(6, allowed_chars='0123456789') + + link = FamilyLink.objects.create( + parent_user_id=request.user.id, + child_phone=v['childPhone'], + invite_code=code, + ) + + data = {'familyLinkId': str(link.pk), 'inviteCode': link.invite_code, 'status': link.status} + return Response(FamilyLinkInviteResponseSerializer(data).data) + + +class FamilyLinkAcceptView(APIView): + """POST /api/family/link/accept — تأیید دعوت توسط فرزند""" + + permission_classes = [IsAuthenticated] + + @extend_schema( + request=FamilyLinkAcceptRequestSerializer, + responses=FamilyLinkAcceptResponseSerializer, + tags=['Family (family)'], + ) + def post(self, request): + serializer = FamilyLinkAcceptRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + v = serializer.validated_data + + link = get_object_or_404(FamilyLink, invite_code=v['inviteCode'], status='pending') + + # ⚠️ اصلاح امنیتی: قبلاً هر کاربر لاگین‌کرده‌ای با دونستنِ inviteCode + # می‌تونست دعوتِ یک والد رو claim کنه، حتی اگه اون دعوت اصلاً برای + # شماره‌ی او نبود (مثلاً کد رو حدس بزنه یا از جای دیگه‌ای ببینه). + # حالا اگه دعوت برای یک شماره‌ی مشخص صادر شده باشه، فقط کاربری که + # شماره‌اش با child_phone یکی باشه می‌تونه اون رو accept کنه. + if link.child_phone and link.child_phone != request.user.phone: + raise PermissionDenied('این دعوت متعلق به شماره‌ی دیگری است.') + + link.child_user_id = request.user.id + link.status = 'accepted' + link.accepted_at = timezone.now() + link.save(update_fields=['child_user_id', 'status', 'accepted_at']) + + # رویداد FamilyLinkAccepted — طبق سند معماری، Notifications فقط + # event-listener‌ه؛ دقیقاً همون الگوی notify() که در + # support/admin.py هنگام پاسخ تیکت صدا زده می‌شه. + from notifications.services import notify + + notify( + user_id=link.parent_user_id, + type='family_link_accepted', + title='فرزند شما دعوت را پذیرفت', + related_id=link.id, + ) + + data = {'familyLinkId': str(link.pk), 'status': link.status, 'acceptedAt': link.accepted_at} + return Response(FamilyLinkAcceptResponseSerializer(data).data) + + +class FamilyLinkStatusView(APIView): + """GET /api/family/link — وضعیت اتصال والد-فرزند (برای هر دو طرف)""" + + permission_classes = [IsAuthenticated] + + @extend_schema(responses=FamilyLinkStatusResponseSerializer, tags=['Family (family)']) + def get(self, request): + link = FamilyLink.objects.filter( + Q(parent_user_id=request.user.id) | Q(child_user_id=request.user.id) + ).first() + if not link: + raise NotFound('اتصال خانوادگی پیدا نشد.') + + child_name = '' + if link.child_user_id: + from accounts.models import User as AccountUser + + child = AccountUser.objects.filter(id=link.child_user_id).first() + child_name = child.name if child else '' + + data = {'familyLinkId': str(link.pk), 'status': link.status, 'childName': child_name} + return Response(FamilyLinkStatusResponseSerializer(data).data) + + +class FamilySettingsView(APIView): + """GET/PATCH /api/family/settings — تنظیمات کنترل والدین""" + + permission_classes = [IsAuthenticated] + + def _get_settings(self, request): + link = get_object_or_404(FamilyLink, parent_user_id=request.user.id, status='accepted') + settings_obj, _ = ParentalSettings.objects.get_or_create(family_link=link) + return settings_obj + + @extend_schema(responses=FamilySettingsResponseSerializer, tags=['Family (family)']) + def get(self, request): + settings_obj = self._get_settings(request) + data = { + 'dailyLimitMinutes': settings_obj.daily_limit_minutes, + 'quietHoursStart': settings_obj.quiet_hours_start, + 'quietHoursEnd': settings_obj.quiet_hours_end, + } + return Response(FamilySettingsResponseSerializer(data).data) + + @extend_schema( + request=FamilySettingsUpdateRequestSerializer, + responses=FamilySettingsUpdateResponseSerializer, + tags=['Family (family)'], + ) + def patch(self, request): + settings_obj = self._get_settings(request) + serializer = FamilySettingsUpdateRequestSerializer(data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + v = serializer.validated_data + + update_fields = [] + if 'dailyLimitMinutes' in v: + settings_obj.daily_limit_minutes = v['dailyLimitMinutes'] + update_fields.append('daily_limit_minutes') + if 'quietHoursStart' in v: + settings_obj.quiet_hours_start = v['quietHoursStart'] + update_fields.append('quiet_hours_start') + if 'quietHoursEnd' in v: + settings_obj.quiet_hours_end = v['quietHoursEnd'] + update_fields.append('quiet_hours_end') + + if update_fields: + settings_obj.save(update_fields=update_fields) + + return Response(FamilySettingsUpdateResponseSerializer({'success': True}).data) + + +class FamilyWeeklyReportView(APIView): + """ + GET /api/family/weekly-report — آخرین گزارش هفتگی + + طبق سند معماری، این گزارش خودش داده تکراری نگه نمی‌داره و از API + اپ‌های Progress و Curriculum خونده می‌شه؛ چون این پروژه یک مونولیت + جنگویی‌ه، همون «خوندن» با import مستقیم مدل‌های آن دو اپ پیاده‌سازی + شده — دقیقاً همون الگویی که progress/views.py (LeaderboardView) برای + خوندن نام کاربر از accounts.User استفاده می‌کنه. + """ + + permission_classes = [IsAuthenticated] + + @extend_schema(responses=FamilyWeeklyReportResponseSerializer, tags=['Family (family)']) + def get(self, request): + link = get_object_or_404(FamilyLink, parent_user_id=request.user.id, status='accepted') + + week_start = _week_start(timezone.now()) + log, created = WeeklyReportLog.objects.get_or_create( + family_link=link, week_start=week_start, defaults={'sent_at': timezone.now()} + ) + + if created: + from notifications.services import notify + + notify( + user_id=link.parent_user_id, + type='weekly_report_ready', + title='گزارش هفتگی فرزند شما آماده است', + related_id=log.id, + ) + + from content.models import UserLessonCompletion + from progress.models import StreakRecord + + completions = UserLessonCompletion.objects.filter( + user_id=link.child_user_id, created_at__date__gte=week_start + ) + xp_earned = completions.aggregate(total=Sum('xp_earned'))['total'] or 0 + lessons_completed = completions.count() + + streak = StreakRecord.objects.filter(user_id=link.child_user_id).first() + current_streak = streak.current_streak if streak else 0 + + if not log.opened_at: + log.opened_at = timezone.now() + log.save(update_fields=['opened_at']) + + data = { + 'weekStart': week_start, + 'xpEarned': xp_earned, + 'lessonsCompleted': lessons_completed, + 'currentStreak': current_streak, + 'openedAt': log.opened_at, + } + return Response(FamilyWeeklyReportResponseSerializer(data).data) diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..8e7ac79 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/notifications/__init__.py b/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notifications/admin.py b/notifications/admin.py new file mode 100644 index 0000000..e436b80 --- /dev/null +++ b/notifications/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin + +from .models import Notification + + +@admin.register(Notification) +class NotificationAdmin(admin.ModelAdmin): + list_display = ('id', 'user_id', 'type', 'title', 'is_read', 'created_at') + list_filter = ('type', 'is_read') + search_fields = ('title', 'body') diff --git a/notifications/apps.py b/notifications/apps.py new file mode 100644 index 0000000..001b4f9 --- /dev/null +++ b/notifications/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'notifications' diff --git a/notifications/migrations/0001_initial.py b/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..f917971 --- /dev/null +++ b/notifications/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 5.2.17 on 2026-08-10 12:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_id', models.IntegerField(db_index=True)), + ('type', models.CharField(choices=[ + ('xp_earned', 'امتیاز گرفتی'), + ('lesson_completed', 'درس تکمیل شد'), + ('order_paid', 'پرداخت موفق'), + ('ticket_replied', 'پاسخ تیکت'), + ('family_link_accepted', 'اتصال خانواده تأیید شد'), + ('weekly_report_ready', 'گزارش هفتگی آماده شد'), + ], max_length=30)), + ('title', models.CharField(max_length=150)), + ('body', models.CharField(blank=True, max_length=300)), + ('related_id', models.IntegerField(blank=True, null=True)), + ('is_read', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/notifications/migrations/__init__.py b/notifications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notifications/models.py b/notifications/models.py new file mode 100644 index 0000000..a617a10 --- /dev/null +++ b/notifications/models.py @@ -0,0 +1,30 @@ +from django.db import models + + +class Notification(models.Model): + # انواع رویدادهایی که این اپ فقط بهشون گوش می‌ده (event-listener)؛ + # خودش تولیدکننده‌ی رویداد نیست. طبق سند معماری: + # LessonCompleted/XPEarned (Progress), OrderPaid (Payment), + # TicketReplied (Support), FamilyLinkAccepted/WeeklyReportReady (Family) + TYPE_CHOICES = [ + ('xp_earned', 'امتیاز گرفتی'), + ('lesson_completed', 'درس تکمیل شد'), + ('order_paid', 'پرداخت موفق'), + ('ticket_replied', 'پاسخ تیکت'), + ('family_link_accepted', 'اتصال خانواده تأیید شد'), + ('weekly_report_ready', 'گزارش هفتگی آماده شد'), + ] + + user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User + type = models.CharField(max_length=30, choices=TYPE_CHOICES) + title = models.CharField(max_length=150) + body = models.CharField(max_length=300, blank=True) + related_id = models.IntegerField(null=True, blank=True) # id رکورد رویداد منبع، بدون FK واقعی + is_read = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return f"notif-{self.id}-{self.type}-user{self.user_id}" diff --git a/notifications/serializers.py b/notifications/serializers.py new file mode 100644 index 0000000..3a10612 --- /dev/null +++ b/notifications/serializers.py @@ -0,0 +1,21 @@ +from rest_framework import serializers + +from .models import Notification + + +class NotificationItemSerializer(serializers.ModelSerializer): + id = serializers.IntegerField() + isRead = serializers.BooleanField(source='is_read') + + class Meta: + model = Notification + fields = ['id', 'type', 'title', 'isRead'] + + +class NotificationListResponseSerializer(serializers.Serializer): + items = NotificationItemSerializer(many=True) + unreadCount = serializers.IntegerField() + + +class MarkReadResponseSerializer(serializers.Serializer): + success = serializers.BooleanField() diff --git a/notifications/services.py b/notifications/services.py new file mode 100644 index 0000000..ba473e7 --- /dev/null +++ b/notifications/services.py @@ -0,0 +1,27 @@ +""" +اپ Notifications برخلاف بقیه‌ی اپ‌ها به هیچ اپی رفرنس سبک نمی‌ده و خودش +تولیدکننده‌ی رویداد نیست؛ فقط event-listener‌ه (طبق سند معماری داده). +چون این پروژه یک مونولیت جنگویی‌ه، «گوش دادن به رویداد» با یک تابع کمکی +پیاده‌سازی شده که سایر اپ‌ها (Progress، Payment، Support، Family) مستقیماً +از این ماژول import و صدا می‌زنن — دقیقاً همون الگوی call_payment_gateway +در payment/services.py و create_order که سرور-به-سرور از subscription +صدا زده می‌شه. +""" +from .models import Notification + + +def notify(*, user_id: int, type: str, title: str, body: str = '', related_id: int | None = None) -> Notification: + """ + ثبت یک اعلان جدید برای کاربر. نمونه‌ی استفاده از اپ‌های دیگه: + + from notifications.services import notify + notify(user_id=order.user_id, type='order_paid', + title='پرداخت با موفقیت انجام شد', related_id=order.id) + """ + return Notification.objects.create( + user_id=user_id, + type=type, + title=title, + body=body, + related_id=related_id, + ) diff --git a/notifications/tests.py b/notifications/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/notifications/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/notifications/urls.py b/notifications/urls.py new file mode 100644 index 0000000..b2b8bb3 --- /dev/null +++ b/notifications/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from .views import NotificationListView, NotificationMarkReadView + +urlpatterns = [ + path('notifications', NotificationListView.as_view(), name='notifications-list'), + path('notifications//read', NotificationMarkReadView.as_view(), name='notifications-mark-read'), +] diff --git a/notifications/views.py b/notifications/views.py new file mode 100644 index 0000000..8812fac --- /dev/null +++ b/notifications/views.py @@ -0,0 +1,37 @@ +from django.shortcuts import get_object_or_404 +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import Notification +from .serializers import MarkReadResponseSerializer, NotificationListResponseSerializer + + +class NotificationListView(APIView): + """GET /api/notifications — فهرست اعلان‌های کاربر (جدیدترین اول)""" + + permission_classes = [IsAuthenticated] + + @extend_schema(responses=NotificationListResponseSerializer, tags=['Notifications (notifications)']) + def get(self, request): + qs = Notification.objects.filter(user_id=request.user.id) + unread_count = qs.filter(is_read=False).count() + + data = {'items': list(qs), 'unreadCount': unread_count} + return Response(NotificationListResponseSerializer(data).data) + + +class NotificationMarkReadView(APIView): + """PATCH /api/notifications/{id}/read — علامت‌گذاری یک اعلان به‌عنوان خوانده‌شده""" + + permission_classes = [IsAuthenticated] + + @extend_schema(responses=MarkReadResponseSerializer, tags=['Notifications (notifications)']) + def patch(self, request, id): + notification = get_object_or_404(Notification, pk=id, user_id=request.user.id) + if not notification.is_read: + notification.is_read = True + notification.save(update_fields=['is_read']) + + return Response(MarkReadResponseSerializer({'success': True}).data) diff --git a/payment/__init__.py b/payment/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/payment/admin.py b/payment/admin.py new file mode 100644 index 0000000..5a92bab --- /dev/null +++ b/payment/admin.py @@ -0,0 +1,21 @@ +from django.contrib import admin + +from .models import Order, Transaction + + +class TransactionInline(admin.TabularInline): + model = Transaction + extra = 0 + + +@admin.register(Order) +class OrderAdmin(admin.ModelAdmin): + list_display = ('id', 'user_id', 'subscription_id', 'amount', 'currency', 'status', 'created_at') + list_filter = ('status', 'currency') + inlines = [TransactionInline] + + +@admin.register(Transaction) +class TransactionAdmin(admin.ModelAdmin): + list_display = ('id', 'order', 'gateway', 'status', 'paid_at') + list_filter = ('gateway', 'status') diff --git a/payment/apps.py b/payment/apps.py new file mode 100644 index 0000000..b4a45c3 --- /dev/null +++ b/payment/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PaymentConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'payment' diff --git a/payment/migrations/0001_initial.py b/payment/migrations/0001_initial.py new file mode 100644 index 0000000..874682e --- /dev/null +++ b/payment/migrations/0001_initial.py @@ -0,0 +1,46 @@ +# Generated by Django 5.2.17 on 2026-08-10 12:00 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Order', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_id', models.IntegerField(db_index=True)), + ('subscription_id', models.IntegerField(blank=True, null=True)), + ('amount', models.PositiveIntegerField()), + ('currency', models.CharField(default='IRT', max_length=10)), + ('status', models.CharField(choices=[('pending', 'در انتظار'), ('paid', 'پرداخت‌شده'), ('failed', 'ناموفق')], default='pending', max_length=10)), + ('idempotency_key', models.CharField(max_length=100, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='Transaction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('gateway', models.CharField(default='zarinpal', max_length=30)), + ('gateway_ref_id', models.CharField(blank=True, max_length=100)), + ('status', models.CharField(max_length=30)), + ('paid_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to='payment.order')), + ], + options={ + 'ordering': ['created_at'], + }, + ), + ] diff --git a/payment/migrations/__init__.py b/payment/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/payment/models.py b/payment/models.py new file mode 100644 index 0000000..a4d4efe --- /dev/null +++ b/payment/models.py @@ -0,0 +1,38 @@ +from django.db import models + + +class Order(models.Model): + """ + تنها اپی که واقعاً پول جابه‌جا می‌کنه. عمداً از Subscription جدا شده + چون منطق و ریسکش فرق داره (درگاه، retry، idempotency). + """ + STATUS_CHOICES = [('pending', 'در انتظار'), ('paid', 'پرداخت‌شده'), ('failed', 'ناموفق')] + + user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User + subscription_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به subscription.Subscription + amount = models.PositiveIntegerField() + currency = models.CharField(max_length=10, default='IRT') + status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending') + idempotency_key = models.CharField(max_length=100, unique=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return f"order-{self.pk}-user{self.user_id}" + + +class Transaction(models.Model): + order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='transactions') + gateway = models.CharField(max_length=30, default='zarinpal') + gateway_ref_id = models.CharField(max_length=100, blank=True) + status = models.CharField(max_length=30) + paid_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['created_at'] + + def __str__(self): + return f"txn-{self.pk}-order{self.order_id}" diff --git a/payment/permissions.py b/payment/permissions.py new file mode 100644 index 0000000..d45fde5 --- /dev/null +++ b/payment/permissions.py @@ -0,0 +1,31 @@ +from django.conf import settings +from rest_framework.permissions import BasePermission + + +class HasInternalServiceKey(BasePermission): + """ + برای endpointهایی مثل POST /api/payment/orders که طبق سند فقط قراره + سرور-به-سرور (از اپ Subscription) صدا زده بشن، نه از یک کلاینت دلخواه. + درخواست باید هدر X-Internal-Key رو برابر settings.INTERNAL_SERVICE_KEY + بفرسته؛ در غیر این صورت 403 برمی‌گرده. + """ + + message = 'این endpoint فقط برای فراخوانی داخلی/سرور-به-سرور است.' + + def has_permission(self, request, view): + provided = request.headers.get('X-Internal-Key', '') + return bool(provided) and provided == settings.INTERNAL_SERVICE_KEY + + +class HasWebhookSecret(BasePermission): + """ + برای POST /api/payment/webhook: باید از سمت درگاه پرداخت بیاد، نه یک + کلاینت دلخواه. تا زمان اتصال درگاه واقعی (که خودش امضای HMAC داره)، + یک shared-secret ساده از طریق هدر X-Webhook-Secret بررسی می‌شه. + """ + + message = 'امضای وب‌هوک نامعتبر است.' + + def has_permission(self, request, view): + provided = request.headers.get('X-Webhook-Secret', '') + return bool(provided) and provided == settings.PAYMENT_WEBHOOK_SECRET diff --git a/payment/serializers.py b/payment/serializers.py new file mode 100644 index 0000000..c437f6b --- /dev/null +++ b/payment/serializers.py @@ -0,0 +1,46 @@ +from rest_framework import serializers + + +class CreateOrderRequestSerializer(serializers.Serializer): + # این فراخوانی سرور-به-سرور از اپ Subscription‌ه، نه از کلاینت؛ به همین + # دلیل userId اینجا مستقیماً در بدنه ارسال می‌شه (نه از توکن استخراج). + userId = serializers.CharField() + amount = serializers.IntegerField() + currency = serializers.CharField(required=False, default='IRT') + subscriptionId = serializers.CharField(required=False, allow_null=True, allow_blank=True) + + +class CreateOrderResponseSerializer(serializers.Serializer): + orderId = serializers.CharField() + status = serializers.CharField() + paymentUrl = serializers.CharField() + + +class WebhookRequestSerializer(serializers.Serializer): + # کال‌بک از سمت درگاه پرداخت میاد، نه از اپ + orderId = serializers.CharField() + gateway = serializers.CharField(required=False, default='zarinpal') + gatewayRefId = serializers.CharField(required=False, allow_blank=True) + # ⚠️ اصلاح امنیتی: قبلاً status یک CharField آزاد بود و هر مقداری + # (حتی مقادیر پشتیبانی‌نشده/اشتباه) رو مستقیم روی Order ذخیره می‌کرد. + # حالا فقط دو وضعیت نهاییِ معتبرِ کال‌بک درگاه پذیرفته می‌شه؛ 'pending' + # وضعیت اولیه‌ی داخلیه و نباید از بیرون توسط webhook قابل‌تنظیم باشه. + status = serializers.ChoiceField(choices=['paid', 'failed']) + + +class WebhookResponseSerializer(serializers.Serializer): + received = serializers.BooleanField() + + +class TransactionSerializer(serializers.Serializer): + id = serializers.CharField() + gateway = serializers.CharField() + status = serializers.CharField() + paidAt = serializers.DateTimeField(allow_null=True) + + +class OrderDetailResponseSerializer(serializers.Serializer): + orderId = serializers.CharField() + status = serializers.CharField() + amount = serializers.IntegerField() + transactions = TransactionSerializer(many=True) diff --git a/payment/services.py b/payment/services.py new file mode 100644 index 0000000..950ea21 --- /dev/null +++ b/payment/services.py @@ -0,0 +1,98 @@ +""" +منطق مشترک اپ Payment که هم از views.py همین اپ و هم به‌صورت سرور-به-سرور +از اپ‌های دیگه (مثل subscription) صدا زده می‌شه. چون این پروژه یک مونولیت +جنگویی‌ه (نه میکروسرویس واقعی)، «فراخوانی سرور-به-سرور» با یک import مستقیم +پیاده‌سازی شده، نه یک HTTP call به خودمون. +""" +from django.db import IntegrityError +from django.db import transaction as db_transaction +from django.utils.crypto import get_random_string +from rest_framework.exceptions import APIException + +from .models import Order + + +class IdempotencyKeyConflict(APIException): + """ + ⚠️ اصلاح امنیتی: قبلاً create_order فقط با idempotency_key سفارش رو + get_or_create می‌کرد، بدون بررسی اینکه صاحب سفارش موجود همون کاربرِ + درخواست‌دهنده‌ست یا پارامترها (amount/currency/subscription) یکی‌ان. + این یعنی یک کاربر می‌تونست با حدس زدن/استفاده‌ی مجدد از یک + Idempotency-Key، سفارشِ کاربر دیگه‌ای رو برگردونه/استفاده کنه، یا با + همون کلید ولی amount متفاوت، رفتار غیرمنتظره بگیره. حالا این حالت‌ها + 409 Conflict برمی‌گردونن. + """ + + status_code = 409 + default_detail = 'این Idempotency-Key قبلاً برای درخواست دیگری استفاده شده است.' + default_code = 'idempotency_key_conflict' + + +def _ensure_idempotent_match(order: Order, *, user_id: int, amount: int, currency: str, + subscription_id: int | None) -> None: + if order.user_id != user_id: + raise IdempotencyKeyConflict( + 'این Idempotency-Key متعلق به کاربر دیگری است و نمی‌تواند استفاده شود.' + ) + if (order.amount != amount or order.currency != currency + or order.subscription_id != subscription_id): + raise IdempotencyKeyConflict( + 'این Idempotency-Key قبلاً با پارامترهای متفاوتی استفاده شده است.' + ) + + +def call_payment_gateway(order: Order) -> str: + """ + استاب فراخوانی درگاه پرداخت واقعی (zarinpal و غیره) — دقیقاً مثل الگوی + generate_tutor_answer در ai_tutor/views.py. فعلاً فقط یک paymentUrl + قطعی و ساختگی برمی‌گردونه تا کل جریان (ساخت سفارش → پرداخت → webhook) + بدون نیاز به کلید API واقعی درگاه، از طریق Swagger قابل تست باشه. + برای اتصال واقعی، این تابع را با فراخوانی HTTP به API درگاه (مثلاً + zarinpal.com/pg/rest/WebGate/PaymentRequest.json) جایگزین کنید. + """ + return f'https://payment.gateway/pay/{order.id}' + + +def create_order(*, user_id: int, amount: int, currency: str = 'IRT', + subscription_id: int | None = None, idempotency_key: str | None = None): + """ + ساخت سفارش با پشتیبانی از Idempotency-Key (بخش ۴.۳ سند API): اگه سفارشی + با همین کلید قبلاً ساخته شده باشه، همون رکورد برگردونده می‌شه، نه یک + رکورد تکراری (مثلاً وقتی کلاینت درخواست رو به‌خاطر timeout دوباره می‌فرسته) + — اما فقط وقتی سفارشِ موجود واقعاً متعلق به همین کاربر و همین پارامترهاست + (وگرنه IdempotencyKeyConflict می‌گیره؛ نگاه کنید به توضیح بالای این فایل). + """ + if not idempotency_key: + idempotency_key = get_random_string(32) + + existing = Order.objects.filter(idempotency_key=idempotency_key).first() + if existing is not None: + _ensure_idempotent_match( + existing, user_id=user_id, amount=amount, currency=currency, + subscription_id=subscription_id, + ) + payment_url = call_payment_gateway(existing) + return existing, payment_url + + try: + with db_transaction.atomic(): + order = Order.objects.create( + user_id=user_id, + subscription_id=subscription_id, + amount=amount, + currency=currency, + status='pending', + idempotency_key=idempotency_key, + ) + except IntegrityError: + # race condition: درخواست هم‌زمان دیگه‌ای با همین کلید زودتر ساخت. + order = Order.objects.filter(idempotency_key=idempotency_key).first() + if order is None: + raise + _ensure_idempotent_match( + order, user_id=user_id, amount=amount, currency=currency, + subscription_id=subscription_id, + ) + + payment_url = call_payment_gateway(order) + return order, payment_url diff --git a/payment/tests.py b/payment/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/payment/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/payment/urls.py b/payment/urls.py new file mode 100644 index 0000000..dec15b1 --- /dev/null +++ b/payment/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import CreateOrderView, OrderDetailView, PaymentWebhookView + +urlpatterns = [ + path('payment/orders', CreateOrderView.as_view(), name='payment-create-order'), + path('payment/webhook', PaymentWebhookView.as_view(), name='payment-webhook'), + path('payment/orders/', OrderDetailView.as_view(), name='payment-order-detail'), +] diff --git a/payment/views.py b/payment/views.py new file mode 100644 index 0000000..5b0c7c6 --- /dev/null +++ b/payment/views.py @@ -0,0 +1,174 @@ +from django.db import transaction +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import Order, Transaction +from .permissions import HasInternalServiceKey, HasWebhookSecret +from .serializers import ( + CreateOrderRequestSerializer, + CreateOrderResponseSerializer, + OrderDetailResponseSerializer, + WebhookRequestSerializer, + WebhookResponseSerializer, +) +from .services import create_order + + +class CreateOrderView(APIView): + """ + POST /api/payment/orders — ساخت سفارش (فراخوانی‌شده از اپ Subscription) + طبق سند API، این یک فراخوانی سرور-به-سرور از اپ Subscription‌ه، نه از + کلاینت. در عمل subscription/views.py مستقیماً تابع create_order رو + import و صدا می‌زنه، پس این endpoint برای جریان عادی از بیرون صدا زده + نمی‌شه؛ برای همین به‌جای AllowAny یک shared-secret داخلی + (HasInternalServiceKey، هدر X-Internal-Key) لازمه، وگرنه هر کلاینتی + می‌تونست با یک userId دلخواه سفارش بسازه. + """ + + permission_classes = [HasInternalServiceKey] + + # ⚠️ افزوده شده: هدرهای موردنیاز (X-Internal-Key, Idempotency-Key) قبلاً + # مستند نشده بودن و از Swagger UI اصلاً قابل ست‌کردن/تست‌کردن نبودن. + @extend_schema( + request=CreateOrderRequestSerializer, + responses=CreateOrderResponseSerializer, + parameters=[ + OpenApiParameter( + 'X-Internal-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=True, + description='کلید مخفیِ فراخوانی داخلی/سرور-به-سرور (settings.INTERNAL_SERVICE_KEY).', + ), + OpenApiParameter( + 'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False, + description=( + 'کلید یکتای idempotency برای جلوگیری از ساخت سفارش تکراری هنگام retry. ' + 'در صورت عدم ارسال، یک کلید تصادفی ساخته می‌شود.' + ), + ), + ], + tags=['Payment (payment)'], + ) + def post(self, request): + serializer = CreateOrderRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + v = serializer.validated_data + + order, payment_url = create_order( + user_id=int(v['userId']), + amount=v['amount'], + currency=v.get('currency') or 'IRT', + subscription_id=int(v['subscriptionId']) if v.get('subscriptionId') else None, + idempotency_key=request.headers.get('Idempotency-Key'), + ) + data = {'orderId': str(order.id), 'status': order.status, 'paymentUrl': payment_url} + return Response(CreateOrderResponseSerializer(data).data) + + +class PaymentWebhookView(APIView): + """ + POST /api/payment/webhook — کال‌بک درگاه پرداخت (از سمت درگاه میاد، نه کلاینت) + + محافظت‌شده با HasWebhookSecret (هدر X-Webhook-Secret) تا هر کلاینتی + نتونه با یک orderId معتبر و status=paid، بدون پرداخت واقعی، اشتراک + فعال کنه. همچنین idempotent شده: اگه سفارش از قبل paid باشه، دوباره + Transaction/notify تکراری نمی‌سازه (مثلاً وقتی درگاه callback رو دوباره + می‌فرسته). + """ + + permission_classes = [HasWebhookSecret] + + # ⚠️ افزوده شده: مستندسازی هدر X-Webhook-Secret که قبلاً در Swagger UI + # قابل تنظیم/تست نبود. + @extend_schema( + request=WebhookRequestSerializer, + responses=WebhookResponseSerializer, + parameters=[ + OpenApiParameter( + 'X-Webhook-Secret', OpenApiTypes.STR, OpenApiParameter.HEADER, required=True, + description='کلید مخفیِ وب‌هوک درگاه پرداخت (settings.PAYMENT_WEBHOOK_SECRET).', + ), + ], + tags=['Payment (payment)'], + ) + def post(self, request): + serializer = WebhookRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + v = serializer.validated_data + gateway_ref_id = v.get('gatewayRefId', '') + + with transaction.atomic(): + order = get_object_or_404(Order.objects.select_for_update(), pk=v['orderId']) + already_paid = order.status == 'paid' + + # ⚠️ اصلاح امنیتی/صحت داده: قبلاً هر بار فراخوانی webhook (حتی + # اگه درگاه به‌خاطر عدم دریافت ۲۰۰ یا هر دلیل دیگه‌ای همون + # کال‌بک رو دوباره می‌فرستاد) یک رکورد Transaction جدید می‌ساخت. + # حالا اگه رکوردی با همین order/status/gatewayRefId از قبل + # موجود باشه، تکراری در نظر گرفته می‌شه و نه Transaction جدید + # ساخته می‌شه و نه اثرات جانبی (فعال‌سازی اشتراک/notify) دوباره + # اجرا می‌شن. + is_duplicate = Transaction.objects.filter( + order=order, status=v['status'], gateway_ref_id=gateway_ref_id, + ).exists() + + if not is_duplicate: + order.status = v['status'] + order.save(update_fields=['status']) + + Transaction.objects.create( + order=order, + gateway=v.get('gateway') or 'zarinpal', + gateway_ref_id=gateway_ref_id, + status=v['status'], + paid_at=timezone.now() if v['status'] == 'paid' else None, + ) + + if v['status'] == 'paid' and not already_paid and not is_duplicate: + if order.subscription_id: + # فعال‌سازی اشتراک بعد از پرداخت موفق. ارتباط با اپ + # subscription هم مثل create_order یک import مستقیم به + # تابع سرویسه (رفرنس سبک با id، نه FK واقعی و نه HTTP + # call به خودمون). + from subscription.services import activate_subscription + activate_subscription(order.subscription_id) + + # رویداد order_paid طبق سند notifications باید ساطع بشه؛ + # قبلاً این فراخوانی جا افتاده بود (فقط اشتراک فعال می‌شد). + from notifications.services import notify + notify( + user_id=order.user_id, + type='order_paid', + title='پرداخت با موفقیت انجام شد', + related_id=order.id, + ) + + return Response(WebhookResponseSerializer({'received': True}).data) + + +class OrderDetailView(APIView): + """GET /api/payment/orders/{id} — وضعیت سفارش""" + + permission_classes = [IsAuthenticated] + + @extend_schema(responses=OrderDetailResponseSerializer, tags=['Payment (payment)']) + def get(self, request, order_id): + order = get_object_or_404(Order, pk=order_id, user_id=request.user.id) + data = { + 'orderId': str(order.id), + 'status': order.status, + 'amount': order.amount, + 'transactions': [ + { + 'id': str(t.id), + 'gateway': t.gateway, + 'status': t.status, + 'paidAt': t.paid_at, + } + for t in order.transactions.all() + ], + } + return Response(OrderDetailResponseSerializer(data).data) diff --git a/progress/__init__.py b/progress/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/progress/admin.py b/progress/admin.py new file mode 100644 index 0000000..69a6117 --- /dev/null +++ b/progress/admin.py @@ -0,0 +1,30 @@ +from django.contrib import admin + +from .models import ActiveDay, LeaderboardEntry, StreakRecord, UserHearts, UserStats + + +@admin.register(UserStats) +class UserStatsAdmin(admin.ModelAdmin): + list_display = ('id', 'user_id', 'xp', 'coins', 'progress_percent') + + +@admin.register(LeaderboardEntry) +class LeaderboardEntryAdmin(admin.ModelAdmin): + list_display = ('id', 'user_id', 'period', 'xp_in_period', 'rank') + list_filter = ('period',) + + +class ActiveDayInline(admin.TabularInline): + model = ActiveDay + extra = 1 + + +@admin.register(StreakRecord) +class StreakRecordAdmin(admin.ModelAdmin): + list_display = ('id', 'user_id', 'current_streak', 'longest_streak') + inlines = [ActiveDayInline] + + +@admin.register(UserHearts) +class UserHeartsAdmin(admin.ModelAdmin): + list_display = ('id', 'user_id', 'current_hearts', 'max_hearts', 'next_refill_at') diff --git a/progress/apps.py b/progress/apps.py new file mode 100644 index 0000000..bf6c754 --- /dev/null +++ b/progress/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ProgressConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'progress' diff --git a/progress/migrations/0001_initial.py b/progress/migrations/0001_initial.py new file mode 100644 index 0000000..1bfb4f8 --- /dev/null +++ b/progress/migrations/0001_initial.py @@ -0,0 +1,68 @@ +# Generated by Django 5.2.17 on 2026-08-10 11:37 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='LeaderboardEntry', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_id', models.IntegerField(db_index=True)), + ('period', models.CharField(choices=[('weekly', 'هفتگی'), ('all_time', 'کل زمان')], max_length=10)), + ('xp_in_period', models.PositiveIntegerField(default=0)), + ('rank', models.PositiveIntegerField()), + ], + options={ + 'ordering': ['rank'], + }, + ), + migrations.CreateModel( + name='StreakRecord', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_id', models.IntegerField(db_index=True, unique=True)), + ('current_streak', models.PositiveIntegerField(default=0)), + ('longest_streak', models.PositiveIntegerField(default=0)), + ], + ), + migrations.CreateModel( + name='UserHearts', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_id', models.IntegerField(db_index=True, unique=True)), + ('current_hearts', models.PositiveSmallIntegerField(default=5)), + ('max_hearts', models.PositiveSmallIntegerField(default=5)), + ('next_refill_at', models.DateTimeField(blank=True, null=True)), + ], + ), + migrations.CreateModel( + name='UserStats', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_id', models.IntegerField(db_index=True, unique=True)), + ('xp', models.PositiveIntegerField(default=0)), + ('coins', models.PositiveIntegerField(default=0)), + ('progress_percent', models.FloatField(default=0)), + ], + ), + migrations.CreateModel( + name='ActiveDay', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField()), + ('streak', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='active_days', to='progress.streakrecord')), + ], + options={ + 'ordering': ['date'], + }, + ), + ] diff --git a/progress/migrations/__init__.py b/progress/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/progress/models.py b/progress/models.py new file mode 100644 index 0000000..b678f74 --- /dev/null +++ b/progress/models.py @@ -0,0 +1,56 @@ +from django.db import models + + +class UserStats(models.Model): + user_id = models.IntegerField(unique=True, db_index=True) # ارجاع سبک به accounts.User + xp = models.PositiveIntegerField(default=0) + coins = models.PositiveIntegerField(default=0) + progress_percent = models.FloatField(default=0) + + def __str__(self): + return f"stats-user{self.user_id}" + + +class LeaderboardEntry(models.Model): + PERIOD_CHOICES = [('weekly', 'هفتگی'), ('all_time', 'کل زمان')] + + user_id = models.IntegerField(db_index=True) + period = models.CharField(max_length=10, choices=PERIOD_CHOICES) + xp_in_period = models.PositiveIntegerField(default=0) + rank = models.PositiveIntegerField() + + class Meta: + ordering = ['rank'] + + def __str__(self): + return f"lb-{self.period}-user{self.user_id}-rank{self.rank}" + + +class StreakRecord(models.Model): + user_id = models.IntegerField(unique=True, db_index=True) + current_streak = models.PositiveIntegerField(default=0) + longest_streak = models.PositiveIntegerField(default=0) + + def __str__(self): + return f"streak-user{self.user_id}" + + +class ActiveDay(models.Model): + streak = models.ForeignKey(StreakRecord, on_delete=models.CASCADE, related_name='active_days') + date = models.DateField() + + class Meta: + ordering = ['date'] + + def __str__(self): + return str(self.date) + + +class UserHearts(models.Model): + user_id = models.IntegerField(unique=True, db_index=True) + current_hearts = models.PositiveSmallIntegerField(default=5) + max_hearts = models.PositiveSmallIntegerField(default=5) + next_refill_at = models.DateTimeField(null=True, blank=True) + + def __str__(self): + return f"hearts-user{self.user_id}" diff --git a/progress/serializers.py b/progress/serializers.py new file mode 100644 index 0000000..4abe634 --- /dev/null +++ b/progress/serializers.py @@ -0,0 +1,41 @@ +from rest_framework import serializers + + +class StreakSummarySerializer(serializers.Serializer): + current = serializers.IntegerField() + longest = serializers.IntegerField() + + +class UserSummaryResponseSerializer(serializers.Serializer): + userId = serializers.CharField() + name = serializers.CharField() + avatar = serializers.CharField() + gradeId = serializers.IntegerField(allow_null=True) + xp = serializers.IntegerField() + coins = serializers.IntegerField() + streak = StreakSummarySerializer() + subscription = serializers.CharField(allow_null=True) + + +class StreakCalendarResponseSerializer(serializers.Serializer): + currentStreak = serializers.IntegerField() + longestStreak = serializers.IntegerField() + activeDays = serializers.ListField(child=serializers.DateField()) + + +class LeaderboardEntrySerializer(serializers.Serializer): + rank = serializers.IntegerField() + userId = serializers.CharField() + name = serializers.CharField() + xpInPeriod = serializers.IntegerField() + + +class LeaderboardResponseSerializer(serializers.Serializer): + period = serializers.ChoiceField(choices=['weekly', 'all_time']) + entries = LeaderboardEntrySerializer(many=True) + + +class UserHeartsResponseSerializer(serializers.Serializer): + currentHearts = serializers.IntegerField() + maxHearts = serializers.IntegerField() + nextRefillAt = serializers.DateTimeField(allow_null=True) diff --git a/progress/tests.py b/progress/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/progress/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/progress/urls.py b/progress/urls.py new file mode 100644 index 0000000..9279d27 --- /dev/null +++ b/progress/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from .views import LeaderboardView, StreakCalendarView, UserHeartsView, UserSummaryView + +urlpatterns = [ + path('user/summary', UserSummaryView.as_view(), name='user-summary'), + path('user/streak/calendar', StreakCalendarView.as_view(), name='streak-calendar'), + path('leaderboard', LeaderboardView.as_view(), name='leaderboard'), + path('user/hearts', UserHeartsView.as_view(), name='user-hearts'), +] diff --git a/progress/views.py b/progress/views.py new file mode 100644 index 0000000..6bf66ab --- /dev/null +++ b/progress/views.py @@ -0,0 +1,109 @@ +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import LeaderboardEntry, StreakRecord, UserHearts, UserStats +from .serializers import ( + LeaderboardResponseSerializer, + StreakCalendarResponseSerializer, + UserHeartsResponseSerializer, + UserSummaryResponseSerializer, +) + + +class UserSummaryView(APIView): + """GET /api/user/summary — پروفایل + آمار + استریک (ادغام‌شده)""" + + permission_classes = [IsAuthenticated] + + @extend_schema(responses=UserSummaryResponseSerializer, tags=['Progress & Gamification (progress)']) + def get(self, request): + user = request.user # accounts.User، از طریق TadrisinoTokenAuthentication + stats = UserStats.objects.filter(user_id=user.id).first() + streak = StreakRecord.objects.filter(user_id=user.id).first() + + data = { + 'userId': str(user.id), + 'name': user.name, + 'avatar': user.avatar, + 'gradeId': user.grade_id, + 'xp': stats.xp if stats else 0, + 'coins': stats.coins if stats else 0, + 'streak': { + 'current': streak.current_streak if streak else 0, + 'longest': streak.longest_streak if streak else 0, + }, + # اپ subscription هنوز در این فاز پیاده‌سازی نشده؛ فعلاً null. + 'subscription': None, + } + return Response(UserSummaryResponseSerializer(data).data) + + +class StreakCalendarView(APIView): + """GET /api/user/streak/calendar — تقویم کامل روزهای فعال""" + + permission_classes = [IsAuthenticated] + + @extend_schema(responses=StreakCalendarResponseSerializer, tags=['Progress & Gamification (progress)']) + def get(self, request): + user = request.user + streak = StreakRecord.objects.filter(user_id=user.id).first() + + data = { + 'currentStreak': streak.current_streak if streak else 0, + 'longestStreak': streak.longest_streak if streak else 0, + 'activeDays': list(streak.active_days.values_list('date', flat=True)) if streak else [], + } + return Response(StreakCalendarResponseSerializer(data).data) + + +class LeaderboardView(APIView): + """GET /api/leaderboard?period= — رتبه‌بندی هفتگی/ماهانه""" + + @extend_schema( + parameters=[OpenApiParameter('period', OpenApiTypes.STR, required=False, + enum=['weekly', 'all_time'])], + responses=LeaderboardResponseSerializer, + tags=['Progress & Gamification (progress)'], + ) + def get(self, request): + period = request.query_params.get('period', 'weekly') + entries = LeaderboardEntry.objects.filter(period=period).order_by('rank')[:50] + + # نام کاربر از accounts.User خونده می‌شه (فقط برای نمایش، بدون FK واقعی). + from accounts.models import User as AccountUser + user_ids = [e.user_id for e in entries] + names = {u.id: u.name for u in AccountUser.objects.filter(id__in=user_ids)} + + data = { + 'period': period, + 'entries': [ + { + 'rank': e.rank, + 'userId': str(e.user_id), + 'name': names.get(e.user_id, ''), + 'xpInPeriod': e.xp_in_period, + } + for e in entries + ], + } + return Response(LeaderboardResponseSerializer(data).data) + + +class UserHeartsView(APIView): + """GET /api/user/hearts — وضعیت فعلی جان‌ها (هارت)""" + + permission_classes = [IsAuthenticated] + + @extend_schema(responses=UserHeartsResponseSerializer, tags=['Progress & Gamification (progress)']) + def get(self, request): + user = request.user + hearts, _ = UserHearts.objects.get_or_create(user_id=user.id) + data = { + 'currentHearts': hearts.current_hearts, + 'maxHearts': hearts.max_hearts, + 'nextRefillAt': hearts.next_refill_at, + } + return Response(UserHeartsResponseSerializer(data).data) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1d5fcdb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Django==5.2.17 +djangorestframework==3.18.0 +drf-spectacular==0.30.0 diff --git a/subscription/__init__.py b/subscription/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/subscription/admin.py b/subscription/admin.py new file mode 100644 index 0000000..07e3205 --- /dev/null +++ b/subscription/admin.py @@ -0,0 +1,14 @@ +from django.contrib import admin + +from .models import Plan, Subscription + + +@admin.register(Plan) +class PlanAdmin(admin.ModelAdmin): + list_display = ('id', 'title', 'price', 'currency', 'discount_percent', 'popular') + + +@admin.register(Subscription) +class SubscriptionAdmin(admin.ModelAdmin): + list_display = ('id', 'user_id', 'plan', 'status', 'started_at', 'expires_at', 'order_id') + list_filter = ('status',) diff --git a/subscription/apps.py b/subscription/apps.py new file mode 100644 index 0000000..e14c1d4 --- /dev/null +++ b/subscription/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SubscriptionConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'subscription' diff --git a/subscription/migrations/0001_initial.py b/subscription/migrations/0001_initial.py new file mode 100644 index 0000000..c1a1a84 --- /dev/null +++ b/subscription/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.17 on 2026-08-10 12:00 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Plan', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=50)), + ('price', models.PositiveIntegerField()), + ('currency', models.CharField(default='IRT', max_length=10)), + ('discount_percent', models.PositiveSmallIntegerField(default=0)), + ('popular', models.BooleanField(default=False)), + ], + ), + migrations.CreateModel( + name='Subscription', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_id', models.IntegerField(db_index=True, unique=True)), + ('status', models.CharField(choices=[('pending', 'در انتظار پرداخت'), ('active', 'فعال'), ('expired', 'منقضی'), ('canceled', 'لغوشده')], default='pending', max_length=15)), + ('started_at', models.DateTimeField(auto_now_add=True)), + ('expires_at', models.DateTimeField(blank=True, null=True)), + ('order_id', models.IntegerField(blank=True, null=True)), + ('plan', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='subscriptions', to='subscription.plan')), + ], + ), + ] diff --git a/subscription/migrations/0002_plan_duration_days.py b/subscription/migrations/0002_plan_duration_days.py new file mode 100644 index 0000000..5b54daf --- /dev/null +++ b/subscription/migrations/0002_plan_duration_days.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.17 on 2026-08-10 17:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('subscription', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='plan', + name='duration_days', + field=models.PositiveIntegerField(default=30), + ), + ] diff --git a/subscription/migrations/__init__.py b/subscription/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/subscription/models.py b/subscription/models.py new file mode 100644 index 0000000..616d2f5 --- /dev/null +++ b/subscription/models.py @@ -0,0 +1,55 @@ +from django.db import models + + +class Plan(models.Model): + """ + ⚠️ اصلاح نسبت به سند اولیه: نمونه‌ی پاسخ GET /api/subscription/plans در + سند Endpointهای API یک فیلد boolean به اسم `popular` داره («ماهانه» → + false، «سالانه» → true) که در دیاگرام مدل‌ها (سند معماری داده) برای + Plan اصلاً ذکر نشده. چون بدون این فیلد نمی‌شه دقیقاً همون response + schema رو پیاده‌سازی کرد، اینجا اضافه شده — دقیقاً مثل فیکس is_correct + روی content.Choice در فاز ۱. + """ + title = models.CharField(max_length=50) + price = models.PositiveIntegerField() + currency = models.CharField(max_length=10, default='IRT') + discount_percent = models.PositiveSmallIntegerField(default=0) + popular = models.BooleanField(default=False) # <-- اصلاح/افزوده شده + # ⚠️ اصلاح دیگه: سند طراحی هیچ فیلد duration‌ای برای Plan نداره، در + # نتیجه‌ی این نبود، activate_subscription قبلاً مدت اشتراک رو با یک + # قاعده‌ی stub (چک کردن کلمه‌ی «سالانه»/yearly در عنوان) حدس می‌زد که + # شکننده و وابسته به متن title بود. duration_days اینجا به‌عنوان منبع + # صریح و قابل‌اتکای مدت اشتراک اضافه شده؛ پیش‌فرض ۳۰ روز. + duration_days = models.PositiveIntegerField(default=30) # <-- اصلاح/افزوده شده + + def __str__(self): + return self.title + + +class Subscription(models.Model): + """ + ⚠️ اصلاح نسبت به سند اولیه: STATUS_CHOICES در سند فقط شامل + active/expired/canceled بود. اما در جریان POST /api/subscription/purchase + یک Subscription قبل از تایید پرداخت (که با payment webhook میاد) ساخته + می‌شه؛ بدون یک وضعیت میانی، راهی برای نمایش «سفارش ثبت شده ولی هنوز + پرداخت تایید نشده» وجود نداره. برای همین وضعیت pending اضافه و به‌عنوان + پیش‌فرض ساخت (به‌جای active در سند) تنظیم شده؛ فعال‌سازی واقعی توسط + subscription.services.activate_subscription بعد از webhook پرداخت انجام + می‌شه. + """ + STATUS_CHOICES = [ + ('pending', 'در انتظار پرداخت'), # <-- اصلاح/افزوده شده + ('active', 'فعال'), + ('expired', 'منقضی'), + ('canceled', 'لغوشده'), + ] + + user_id = models.IntegerField(unique=True, db_index=True) # ارجاع سبک به accounts.User + plan = models.ForeignKey(Plan, on_delete=models.SET_NULL, null=True, related_name='subscriptions') + status = models.CharField(max_length=15, choices=STATUS_CHOICES, default='pending') + started_at = models.DateTimeField(auto_now_add=True) + expires_at = models.DateTimeField(null=True, blank=True) + order_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به payment.Order + + def __str__(self): + return f"sub-user{self.user_id}-{self.status}" diff --git a/subscription/serializers.py b/subscription/serializers.py new file mode 100644 index 0000000..0066b83 --- /dev/null +++ b/subscription/serializers.py @@ -0,0 +1,33 @@ +from rest_framework import serializers + + +class PlanSerializer(serializers.Serializer): + id = serializers.CharField() + title = serializers.CharField() + price = serializers.IntegerField() + currency = serializers.CharField(required=False) + popular = serializers.BooleanField() + discountPercent = serializers.IntegerField(required=False) + durationDays = serializers.IntegerField(required=False) + + +class PlansListResponseSerializer(serializers.Serializer): + plans = PlanSerializer(many=True) + + +class PurchaseRequestSerializer(serializers.Serializer): + # هدر Idempotency-Key هم روی همین درخواست لازمه (بخش ۴.۳ سند API)؛ + # از request.headers خونده می‌شه، نه از بدنه. + planId = serializers.CharField() + paymentMethod = serializers.CharField(required=False, default='zarinpal') + + +class PurchaseResponseSerializer(serializers.Serializer): + orderId = serializers.CharField() + paymentUrl = serializers.CharField() + + +class SubscriptionStatusResponseSerializer(serializers.Serializer): + active = serializers.BooleanField() + planId = serializers.CharField(allow_null=True) + expiresAt = serializers.DateTimeField(allow_null=True) diff --git a/subscription/services.py b/subscription/services.py new file mode 100644 index 0000000..8dcf2fb --- /dev/null +++ b/subscription/services.py @@ -0,0 +1,23 @@ +from django.utils import timezone + +from .models import Subscription + + +def activate_subscription(subscription_id: int): + """ + بعد از تایید پرداخت (payment webhook) صدا زده می‌شه؛ اشتراک رو active + می‌کنه و expires_at رو بر اساس Plan.duration_days محاسبه می‌کنه. + (قبلاً چون Plan فیلد duration‌ای نداشت، این مقدار با یک قاعده‌ی stub + بر اساس متن title حدس زده می‌شد؛ الان duration_days مستقیماً از مدل + Plan خونده می‌شه.) + """ + try: + subscription = Subscription.objects.get(pk=subscription_id) + except Subscription.DoesNotExist: + return None + + 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']) + return subscription diff --git a/subscription/tests.py b/subscription/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/subscription/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/subscription/urls.py b/subscription/urls.py new file mode 100644 index 0000000..53cf600 --- /dev/null +++ b/subscription/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import PlansListView, PurchaseView, SubscriptionStatusView + +urlpatterns = [ + path('subscription/plans', PlansListView.as_view(), name='subscription-plans'), + path('subscription/purchase', PurchaseView.as_view(), name='subscription-purchase'), + path('subscription/status', SubscriptionStatusView.as_view(), name='subscription-status'), +] diff --git a/subscription/views.py b/subscription/views.py new file mode 100644 index 0000000..9bcd9e7 --- /dev/null +++ b/subscription/views.py @@ -0,0 +1,115 @@ +from django.db import transaction +from django.shortcuts import get_object_or_404 +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from payment.services import create_order + +from .models import Plan, Subscription +from .serializers import ( + PlansListResponseSerializer, + PurchaseRequestSerializer, + PurchaseResponseSerializer, + SubscriptionStatusResponseSerializer, +) + + +class PlansListView(APIView): + """GET /api/subscription/plans — لیست پلن‌ها""" + + @extend_schema(responses=PlansListResponseSerializer, tags=['Subscription (subscription)']) + def get(self, request): + plans = Plan.objects.all() + data = { + 'plans': [ + { + 'id': str(p.id), + 'title': p.title, + 'price': p.price, + 'currency': p.currency, + 'popular': p.popular, + 'discountPercent': p.discount_percent, + 'durationDays': p.duration_days, + } + for p in plans + ] + } + return Response(PlansListResponseSerializer(data).data) + + +class PurchaseView(APIView): + """ + POST /api/subscription/purchase — شروع خرید (با هدر Idempotency-Key) + یک Subscription در وضعیت pending می‌سازه/به‌روز می‌کنه، بعد مستقیماً + تابع سرویس اپ Payment رو صدا می‌زنه تا Order بسازه (همون «فراخوانی + سرور-به-سرور» سند API که در این مونولیت به شکل import مستقیم پیاده‌سازی + شده، نه یک HTTP call جدا به خودمون). + """ + + permission_classes = [IsAuthenticated] + + # ⚠️ افزوده شده: مستندسازی هدر Idempotency-Key که قبلاً در Swagger UI + # قابل تنظیم/تست نبود. + @extend_schema( + request=PurchaseRequestSerializer, + responses=PurchaseResponseSerializer, + parameters=[ + OpenApiParameter( + 'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False, + description=( + 'کلید یکتای idempotency برای جلوگیری از ساخت سفارش تکراری هنگام retry. ' + 'در صورت عدم ارسال، یک کلید تصادفی ساخته می‌شود.' + ), + ), + ], + tags=['Subscription (subscription)'], + ) + def post(self, request): + serializer = PurchaseRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + v = serializer.validated_data + user = request.user + + plan = get_object_or_404(Plan, pk=v['planId']) + + # ⚠️ اصلاح: قبلاً اگه create_order (مثلاً به‌خاطر IdempotencyKeyConflict) + # خطا می‌داد، Subscription که یک خط بالاتر update_or_create شده بود + # نیمه‌کاره (status='pending' ولی بدون order_id معتبر) در دیتابیس + # باقی می‌موند. حالا کل عملیات در یک تراکنش اتمیک انجام می‌شه. + with transaction.atomic(): + subscription, _created = Subscription.objects.update_or_create( + user_id=user.id, + defaults={'plan': plan, 'status': 'pending'}, + ) + + order, payment_url = create_order( + user_id=user.id, + amount=plan.price, + currency=plan.currency, + subscription_id=subscription.id, + idempotency_key=request.headers.get('Idempotency-Key'), + ) + subscription.order_id = order.id + subscription.save(update_fields=['order_id']) + + data = {'orderId': str(order.id), 'paymentUrl': payment_url} + return Response(PurchaseResponseSerializer(data).data) + + +class SubscriptionStatusView(APIView): + """GET /api/subscription/status — وضعیت فعلی اشتراک""" + + permission_classes = [IsAuthenticated] + + @extend_schema(responses=SubscriptionStatusResponseSerializer, tags=['Subscription (subscription)']) + def get(self, request): + subscription = Subscription.objects.filter(user_id=request.user.id).first() + data = { + 'active': bool(subscription and subscription.status == 'active'), + 'planId': str(subscription.plan_id) if subscription and subscription.plan_id else None, + 'expiresAt': subscription.expires_at if subscription else None, + } + return Response(SubscriptionStatusResponseSerializer(data).data) diff --git a/support/__init__.py b/support/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/support/admin.py b/support/admin.py new file mode 100644 index 0000000..c91f6c5 --- /dev/null +++ b/support/admin.py @@ -0,0 +1,52 @@ +from django.contrib import admin + +from .models import BugReport, Ticket, TicketMessage + + +class TicketMessageInline(admin.TabularInline): + model = TicketMessage + extra = 0 + readonly_fields = ('created_at',) + + +@admin.register(BugReport) +class BugReportAdmin(admin.ModelAdmin): + list_display = ('id', 'user_id', 'title', 'status', 'linked_ticket_id', 'created_at') + list_filter = ('status',) + search_fields = ('title',) + + +@admin.register(Ticket) +class TicketAdmin(admin.ModelAdmin): + list_display = ('id', 'user_id', 'subject', 'category', 'status', 'priority', 'created_at') + list_filter = ('status', 'priority', 'category') + search_fields = ('subject',) + inlines = [TicketMessageInline] + + +@admin.register(TicketMessage) +class TicketMessageAdmin(admin.ModelAdmin): + list_display = ('id', 'ticket', 'sender', 'created_at') + list_filter = ('sender',) + + def save_model(self, request, obj, form, change): + """ + طبق سند معماری، ارتباط Support→Notifications فقط از طریق رویداد + TicketReplied‌ه، نه رفرنس مدل. چون پاسخ پشتیبانی (sender='support') + از پنل ادمین ثبت می‌شه، رویداد دقیقاً همین‌جا ساطع می‌شه — همون + الگوی notify() که در payment/views.py (PaymentWebhookView) هنگام + پرداخت موفق (status=paid) صدا زده می‌شه. + """ + is_new = obj.pk is None + super().save_model(request, obj, form, change) + + if is_new and obj.sender == 'support': + from notifications.services import notify + + notify( + user_id=obj.ticket.user_id, + type='ticket_replied', + title='پاسخ جدید برای تیکت شما', + body=obj.text[:200], + related_id=obj.ticket_id, + ) diff --git a/support/apps.py b/support/apps.py new file mode 100644 index 0000000..e18c7be --- /dev/null +++ b/support/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SupportConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'support' diff --git a/support/migrations/0001_initial.py b/support/migrations/0001_initial.py new file mode 100644 index 0000000..51efc2f --- /dev/null +++ b/support/migrations/0001_initial.py @@ -0,0 +1,59 @@ +# Generated by Django 5.2.17 on 2026-08-10 12:00 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='BugReport', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_id', models.IntegerField(db_index=True)), + ('title', models.CharField(max_length=150)), + ('description', models.TextField()), + ('screenshot_url', models.URLField(blank=True)), + ('status', models.CharField(choices=[('open', 'باز'), ('resolved', 'برطرف‌شده')], default='open', max_length=15)), + ('linked_ticket_id', models.IntegerField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='Ticket', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_id', models.IntegerField(db_index=True)), + ('subject', models.CharField(max_length=150)), + ('category', models.CharField(max_length=30)), + ('status', models.CharField(choices=[('open', 'باز'), ('in_progress', 'در حال بررسی'), ('closed', 'بسته')], default='open', max_length=15)), + ('priority', models.CharField(choices=[('low', 'کم'), ('normal', 'عادی'), ('high', 'بالا')], default='normal', max_length=10)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='TicketMessage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('sender', models.CharField(choices=[('user', 'کاربر'), ('support', 'پشتیبانی')], max_length=10)), + ('text', models.TextField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='support.ticket')), + ], + options={ + 'ordering': ['created_at'], + }, + ), + ] diff --git a/support/migrations/__init__.py b/support/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/support/models.py b/support/models.py new file mode 100644 index 0000000..1ed15e9 --- /dev/null +++ b/support/models.py @@ -0,0 +1,54 @@ +from django.db import models + + +class BugReport(models.Model): + STATUS_CHOICES = [('open', 'باز'), ('resolved', 'برطرف‌شده')] + + user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User + title = models.CharField(max_length=150) + description = models.TextField() + screenshot_url = models.URLField(blank=True) + status = models.CharField(max_length=15, choices=STATUS_CHOICES, default='open') + # ارجاع سبک اختیاری به Ticket (نه FK واقعی)؛ یعنی این باگ می‌تونه بعداً + # به یک تیکت اسکیل بشه، ولی جوینی بین این دو مدل اجباری نیست. + linked_ticket_id = models.IntegerField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return f"bug-{self.pk}-{self.title}" + + +class Ticket(models.Model): + STATUS_CHOICES = [('open', 'باز'), ('in_progress', 'در حال بررسی'), ('closed', 'بسته')] + PRIORITY_CHOICES = [('low', 'کم'), ('normal', 'عادی'), ('high', 'بالا')] + + user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User + subject = models.CharField(max_length=150) + category = models.CharField(max_length=30) + status = models.CharField(max_length=15, choices=STATUS_CHOICES, default='open') + priority = models.CharField(max_length=10, choices=PRIORITY_CHOICES, default='normal') + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return f"ticket-{self.pk}-{self.subject}" + + +class TicketMessage(models.Model): + SENDER_CHOICES = [('user', 'کاربر'), ('support', 'پشتیبانی')] + + ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name='messages') + sender = models.CharField(max_length=10, choices=SENDER_CHOICES) + text = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['created_at'] + + def __str__(self): + return f"tmsg-{self.pk}-ticket{self.ticket_id}-{self.sender}" diff --git a/support/serializers.py b/support/serializers.py new file mode 100644 index 0000000..5b28bc6 --- /dev/null +++ b/support/serializers.py @@ -0,0 +1,45 @@ +from rest_framework import serializers + + +class BugReportCreateRequestSerializer(serializers.Serializer): + title = serializers.CharField(max_length=150) + description = serializers.CharField() + screenshotUrl = serializers.URLField(required=False, allow_blank=True) + + +class BugReportCreateResponseSerializer(serializers.Serializer): + bugReportId = serializers.CharField() + status = serializers.CharField() + + +class TicketCreateRequestSerializer(serializers.Serializer): + subject = serializers.CharField(max_length=150) + category = serializers.CharField(max_length=30) + priority = serializers.ChoiceField(choices=['low', 'normal', 'high'], required=False) + + +class TicketCreateResponseSerializer(serializers.Serializer): + ticketId = serializers.CharField() + status = serializers.CharField() + + +class TicketMessageItemSerializer(serializers.Serializer): + id = serializers.CharField() + sender = serializers.CharField() + text = serializers.CharField() + + +class TicketDetailResponseSerializer(serializers.Serializer): + ticketId = serializers.CharField() + subject = serializers.CharField() + status = serializers.CharField() + messages = TicketMessageItemSerializer(many=True) + + +class TicketMessageCreateRequestSerializer(serializers.Serializer): + text = serializers.CharField() + + +class TicketMessageCreateResponseSerializer(serializers.Serializer): + messageId = serializers.CharField() + success = serializers.BooleanField() diff --git a/support/tests.py b/support/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/support/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/support/urls.py b/support/urls.py new file mode 100644 index 0000000..8322a1a --- /dev/null +++ b/support/urls.py @@ -0,0 +1,19 @@ +from django.urls import path + +from .views import ( + BugReportCreateView, + TicketCreateView, + TicketDetailView, + TicketMessageCreateView, +) + +urlpatterns = [ + path('support/bug-reports', BugReportCreateView.as_view(), name='support-bug-report-create'), + path('support/tickets', TicketCreateView.as_view(), name='support-ticket-create'), + path('support/tickets/', TicketDetailView.as_view(), name='support-ticket-detail'), + path( + 'support/tickets//messages', + TicketMessageCreateView.as_view(), + name='support-ticket-message-create', + ), +] diff --git a/support/views.py b/support/views.py new file mode 100644 index 0000000..661b64e --- /dev/null +++ b/support/views.py @@ -0,0 +1,126 @@ +from django.shortcuts import get_object_or_404 +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import BugReport, Ticket, TicketMessage +from .serializers import ( + BugReportCreateRequestSerializer, + BugReportCreateResponseSerializer, + TicketCreateRequestSerializer, + TicketCreateResponseSerializer, + TicketDetailResponseSerializer, + TicketMessageCreateRequestSerializer, + TicketMessageCreateResponseSerializer, +) + + +class BugReportCreateView(APIView): + """POST /api/support/bug-reports — ثبت گزارش باگ""" + + permission_classes = [IsAuthenticated] + + @extend_schema( + request=BugReportCreateRequestSerializer, + responses=BugReportCreateResponseSerializer, + tags=['Support (support)'], + ) + def post(self, request): + serializer = BugReportCreateRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + v = serializer.validated_data + + bug = BugReport.objects.create( + user_id=request.user.id, + title=v['title'], + description=v['description'], + screenshot_url=v.get('screenshotUrl', ''), + ) + + data = {'bugReportId': str(bug.pk), 'status': bug.status} + return Response(BugReportCreateResponseSerializer(data).data) + + +class TicketCreateView(APIView): + """POST /api/support/tickets — ایجاد تیکت""" + + permission_classes = [IsAuthenticated] + + @extend_schema( + request=TicketCreateRequestSerializer, + responses=TicketCreateResponseSerializer, + tags=['Support (support)'], + ) + def post(self, request): + serializer = TicketCreateRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + v = serializer.validated_data + + ticket = Ticket.objects.create( + user_id=request.user.id, + subject=v['subject'], + category=v['category'], + priority=v.get('priority', 'normal'), + ) + + data = {'ticketId': str(ticket.pk), 'status': ticket.status} + return Response(TicketCreateResponseSerializer(data).data) + + +class TicketDetailView(APIView): + """ + GET /api/support/tickets/{id} — جزئیات و پیام‌های تیکت + + فقط تیکت متعلق به همون کاربر برمی‌گرده (فیلتر با user_id، دقیقاً + همون الگوی محدودسازی به مالک که در progress/views.py برای + UserStats و در ai_tutor/views.py برای Conversation استفاده شده). + """ + + permission_classes = [IsAuthenticated] + + @extend_schema(responses=TicketDetailResponseSerializer, tags=['Support (support)']) + def get(self, request, ticket_id): + ticket = get_object_or_404(Ticket, pk=ticket_id, user_id=request.user.id) + + messages = [ + {'id': str(m.pk), 'sender': m.sender, 'text': m.text} + for m in ticket.messages.all() + ] + data = { + 'ticketId': str(ticket.pk), + 'subject': ticket.subject, + 'status': ticket.status, + 'messages': messages, + } + return Response(TicketDetailResponseSerializer(data).data) + + +class TicketMessageCreateView(APIView): + """ + POST /api/support/tickets/{id}/messages — ارسال پیام در تیکت + + این Endpoint سمت کاربره، پس sender همیشه 'user' ثبت می‌شه. پاسخ + پشتیبانی (sender='support') از پنل ادمین ثبت می‌شه و طبق سند معماری + از همون‌جا رویداد TicketReplied برای Notifications ساطع می‌شه + (پیاده‌سازی در support/admin.py، نه اینجا). + """ + + permission_classes = [IsAuthenticated] + + @extend_schema( + request=TicketMessageCreateRequestSerializer, + responses=TicketMessageCreateResponseSerializer, + tags=['Support (support)'], + ) + def post(self, request, ticket_id): + ticket = get_object_or_404(Ticket, pk=ticket_id, user_id=request.user.id) + + serializer = TicketMessageCreateRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + v = serializer.validated_data + + message = TicketMessage.objects.create(ticket=ticket, sender='user', text=v['text']) + + data = {'messageId': str(message.pk), 'success': True} + return Response(TicketMessageCreateResponseSerializer(data).data)