diff --git a/CLAUDE.md b/CLAUDE.md index 69f66e2..065eb24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ API docs: Swagger `/api/docs/`, Redoc `/api/redoc/`, admin `/admin/`. |---|---|---| | `accounts` | identity, sessions, OTP auth | `User`, `Profile`, `Session`, `OTPCode` | | `content` | lessons/curriculum | `Grade`, `Unit`, `Node`, `Lesson`, `Question`, `Choice`, `NodeProgress` | -| `progress` | stats, leaderboard, streaks | `UserStats`, `LeaderboardEntry`, `StreakRecord`, `UserHearts` | +| `progress` | stats, leaderboard, streaks, gamification characters | `UserStats`, `LeaderboardEntry`, `StreakRecord`, `UserHearts`, `Character` | | `ai_tutor` | AI assistant chat | `Conversation`, `Message`, `MessageFeedback` | | `subscription` | plans | `Plan`, `Subscription` | | `payment` | orders/transactions (Zarinpal gateway + Cafe Bazaar IAP) | `Order`, `Transaction` | @@ -60,6 +60,8 @@ API docs: Swagger `/api/docs/`, Redoc `/api/redoc/`, admin `/admin/`. **SMS** goes through Kavenegar (`KAVENEGAR_API_KEY`/`KAVENEGAR_SENDER`); failures return explicit 503, not a fake success. +**Gamification characters** (`progress.Character`, Duolingo-mascot-style): an admin-managed catalog (like `subscription.Plan` — created via Django admin or `seed_test_data`, not by users). Unlocking is currently just `UserStats.xp >= Character.unlock_xp` (no separate ownership table); `UserStats.selected_character_id` is the user's active pick, set via `POST /api/characters/select` after that check. First real content beyond the model/plumbing (art assets, more characters, purchase-with-coins, etc.) is expected to land incrementally later. + **Payment (Zarinpal)**: `create_order`/`CreateOrderView` never call Zarinpal directly — `paymentUrl` points at our own `GET /api/payment/orders/{id}/pay` (`PaymentRedirectView`), which calls Zarinpal's PaymentRequest API on first visit (storing `Order.gateway_authority`) and redirects to StartPay; this keeps order creation from depending on Zarinpal's uptime. Zarinpal redirects the user back to `GET /api/payment/zarinpal/callback` (`ZarinpalCallbackView`), which always re-verifies server-to-server via `payment/zarinpal.py::verify_payment` before marking an order paid — a forged `Status=OK` in the query string alone does nothing. Both `payment/zarinpal.py` and `payment/bazaar.py` fail closed (503 / invalid) when their credentials aren't configured, never a fake success. ## Known gaps (per README pre-deploy checklist) diff --git a/accounts/management/commands/seed_test_data.py b/accounts/management/commands/seed_test_data.py index bc31482..9c94f63 100644 --- a/accounts/management/commands/seed_test_data.py +++ b/accounts/management/commands/seed_test_data.py @@ -134,6 +134,9 @@ class Command(BaseCommand): BugReport.objects.filter(user_id__in=user_ids).delete() FamilyLink.objects.filter(parent_user_id__in=user_ids).delete() + from progress.models import Character + Character.objects.filter(slug__startswith='seed-').delete() + Plan.objects.filter(title__startswith=SEED_TAG).delete() DiscountCode.objects.filter(code='SEEDTEST10').delete() Offer.objects.filter(code='SEEDTRIAL7').delete() @@ -153,6 +156,7 @@ class Command(BaseCommand): def _seed(self): main_user, guest_user, parent_user, child_user, referred_user = self._seed_accounts() self._seed_content_and_progress(main_user) + self._seed_characters() self._seed_ai_tutor(main_user) plan_monthly, plan_yearly = self._seed_subscription_and_payment(main_user) self._seed_discount(main_user) @@ -303,6 +307,26 @@ class Command(BaseCommand): 'content/progress: 1 پایه، 1 مبحث، 2 Node، 2 درس، 2 سوال و پیشرفتِ کاربرِ اصلی آماده شد.' )) + # -- گیمیفیکیشن: کاراکترها ------------------------------------------------- + def _seed_characters(self): + from progress.models import Character + + # unlock_xp پلکانی، هماهنگ با xp=10ی کاربرِ اصلی که بالاتر ساخته شد: + # فقط اولین کاراکتر (unlock_xp=0) براش باز است، بقیه هنوز قفلن — + # برای این‌که هم مسیرِ «باز» و هم مسیرِ «قفل» در تست دستی قابل‌دیدن باشه. + characters = [ + ('seed-owl', f'{SEED_TAG} جغد کنجکاو', 'owl', 0, 1), + ('seed-fox', f'{SEED_TAG} روباه باهوش', 'fox', 100, 2), + ('seed-turtle', f'{SEED_TAG} لاک‌پشت صبور', 'turtle', 300, 3), + ('seed-lion', f'{SEED_TAG} شیر شجاع', 'lion', 600, 4), + ] + for slug, name, icon, unlock_xp, order in characters: + Character.objects.get_or_create( + slug=slug, defaults=dict(name=name, icon=icon, unlock_xp=unlock_xp, order=order), + ) + + self.stdout.write(self.style.SUCCESS(f'progress: {len(characters)} کاراکترِ گیمیفیکیشن آماده شد.')) + # -- ai_tutor ------------------------------------------------------------- def _seed_ai_tutor(self, main_user): from ai_tutor.models import Conversation, Message, MessageFeedback diff --git a/progress/admin.py b/progress/admin.py index 69a6117..6644a22 100644 --- a/progress/admin.py +++ b/progress/admin.py @@ -1,11 +1,17 @@ from django.contrib import admin -from .models import ActiveDay, LeaderboardEntry, StreakRecord, UserHearts, UserStats +from .models import ActiveDay, Character, LeaderboardEntry, StreakRecord, UserHearts, UserStats @admin.register(UserStats) class UserStatsAdmin(admin.ModelAdmin): - list_display = ('id', 'user_id', 'xp', 'coins', 'progress_percent') + list_display = ('id', 'user_id', 'xp', 'coins', 'progress_percent', 'selected_character_id') + + +@admin.register(Character) +class CharacterAdmin(admin.ModelAdmin): + list_display = ('id', 'name', 'slug', 'icon', 'unlock_xp', 'order') + ordering = ('order', 'unlock_xp') @admin.register(LeaderboardEntry) diff --git a/progress/migrations/0002_character_userstats_selected_character_id.py b/progress/migrations/0002_character_userstats_selected_character_id.py new file mode 100644 index 0000000..0365e17 --- /dev/null +++ b/progress/migrations/0002_character_userstats_selected_character_id.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.17 on 2026-09-06 10:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('progress', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Character', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('slug', models.SlugField(unique=True)), + ('name', models.CharField(max_length=50)), + ('icon', models.CharField(max_length=100)), + ('unlock_xp', models.PositiveIntegerField(default=0)), + ('order', models.PositiveIntegerField(default=0)), + ], + options={ + 'ordering': ['order', 'unlock_xp'], + }, + ), + migrations.AddField( + model_name='userstats', + name='selected_character_id', + field=models.IntegerField(blank=True, null=True), + ), + ] diff --git a/progress/models.py b/progress/models.py index 6b8482d..12849f3 100644 --- a/progress/models.py +++ b/progress/models.py @@ -11,6 +11,10 @@ class UserStats(models.Model): xp = models.PositiveIntegerField(default=0) coins = models.PositiveIntegerField(default=0) progress_percent = models.FloatField(default=0) + # ارجاع سبک به Character؛ کاراکترِ انتخابیِ کاربر (مثلِ ماسکاتِ Duolingo). + # می‌تونه به کاراکترِ هنوز باز‌نشده اشاره نکنه — CharacterSelectView قبل از + # ست‌کردن چک می‌کنه unlock_xp <= xp. + selected_character_id = models.IntegerField(null=True, blank=True) def __str__(self): return f"stats-user{self.user_id}" @@ -92,3 +96,27 @@ class UserHearts(models.Model): self.current_hearts -= 1 if self.next_refill_at is None and self.current_hearts < self.max_hearts: self.next_refill_at = timezone.now() + HEART_REFILL_INTERVAL + + +class Character(models.Model): + """ + کاراکترهای گیمیفیکیشن (مثلِ ماسکات‌های Duolingo) — یک کاتالوگِ ثابت که از + پنلِ ادمین مدیریت می‌شه (دقیقاً مثلِ subscription.Plan)، نه یک چیزی که + کاربر خودش می‌سازه. فعلاً باز/بسته‌بودن فقط تابعی از XP کاربره (بدونِ + جدولِ جداگانه‌ی مالکیت)؛ اگه بعداً «خریدن با coin» یا شرطِ دیگه‌ای لازم + شد، همین‌جا اضافه می‌شه. + """ + slug = models.SlugField(max_length=50, unique=True) + name = models.CharField(max_length=50) + # کلیدی که فرانت به تصویر/انیمیشنِ واقعی نگاشت می‌ده — همون الگویِ + # content.Lesson.mascot_animation و accounts.User.avatar؛ فعلاً آپلودِ + # asset واقعی نداریم. + icon = models.CharField(max_length=100) + unlock_xp = models.PositiveIntegerField(default=0) + order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ['order', 'unlock_xp'] + + def __str__(self): + return self.name diff --git a/progress/serializers.py b/progress/serializers.py index 08fcd30..473f2b3 100644 --- a/progress/serializers.py +++ b/progress/serializers.py @@ -40,3 +40,26 @@ class UserHeartsResponseSerializer(serializers.Serializer): maxHearts = serializers.IntegerField() nextRefillAt = serializers.DateTimeField(allow_null=True) isUnlimited = serializers.BooleanField() + + +class CharacterSerializer(serializers.Serializer): + characterId = serializers.CharField() + slug = serializers.CharField() + name = serializers.CharField() + icon = serializers.CharField() + unlockXp = serializers.IntegerField() + unlocked = serializers.BooleanField() + selected = serializers.BooleanField() + + +class CharacterListResponseSerializer(serializers.Serializer): + characters = CharacterSerializer(many=True) + + +class SelectCharacterRequestSerializer(serializers.Serializer): + characterId = serializers.CharField() + + +class SelectCharacterResponseSerializer(serializers.Serializer): + characterId = serializers.CharField() + selected = serializers.BooleanField() diff --git a/progress/tests.py b/progress/tests.py index 07a264b..8ab0f50 100644 --- a/progress/tests.py +++ b/progress/tests.py @@ -7,7 +7,7 @@ from rest_framework.test import APIClient from accounts.authentication import issue_access_token from accounts.models import User -from .models import HEART_REFILL_INTERVAL, UserHearts +from .models import HEART_REFILL_INTERVAL, Character, UserHearts, UserStats class UserHeartsRegenTests(TestCase): @@ -60,3 +60,72 @@ class UserHeartsRegenTests(TestCase): response = self._get_hearts() self.assertEqual(response.data['currentHearts'], 5) self.assertIsNone(response.data['nextRefillAt']) + + +class CharacterGamificationTests(TestCase): + """کاتالوگِ کاراکترها (مثلِ ماسکات‌های Duolingo): باز/بسته‌بودن تابعی از + XP کاربره، و فقط کاراکترِ باز‌شده قابلِ انتخاب‌شدنه.""" + + def setUp(self): + self.client = APIClient() + self.user = User.objects.create(device_id='device-characters-1', is_guest=True, name='') + self.token = issue_access_token(self.user.id) + + self.owl = Character.objects.create(slug='owl', name='جغد', icon='owl', unlock_xp=0, order=1) + self.fox = Character.objects.create(slug='fox', name='روباه', icon='fox', unlock_xp=100, order=2) + + def _list(self): + return self.client.get('/api/characters', HTTP_AUTHORIZATION=f'Bearer {self.token}') + + def _select(self, character_id): + return self.client.post( + '/api/characters/select', {'characterId': str(character_id)}, + format='json', HTTP_AUTHORIZATION=f'Bearer {self.token}', + ) + + def test_list_reports_locked_and_unlocked_by_xp(self): + UserStats.objects.create(user_id=self.user.id, xp=50) + + response = self._list() + + self.assertEqual(response.status_code, 200) + by_slug = {c['slug']: c for c in response.data['characters']} + self.assertTrue(by_slug['owl']['unlocked']) + self.assertFalse(by_slug['fox']['unlocked']) + + def test_list_with_no_stats_yet_only_zero_xp_characters_unlocked(self): + response = self._list() + + self.assertEqual(response.status_code, 200) + by_slug = {c['slug']: c for c in response.data['characters']} + self.assertTrue(by_slug['owl']['unlocked']) + self.assertFalse(by_slug['fox']['unlocked']) + + def test_can_select_unlocked_character(self): + UserStats.objects.create(user_id=self.user.id, xp=10) + + response = self._select(self.owl.id) + + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data['selected']) + stats = UserStats.objects.get(user_id=self.user.id) + self.assertEqual(stats.selected_character_id, self.owl.id) + + def test_cannot_select_locked_character(self): + UserStats.objects.create(user_id=self.user.id, xp=10) + + response = self._select(self.fox.id) + + self.assertEqual(response.status_code, 400) + stats = UserStats.objects.get(user_id=self.user.id) + self.assertIsNone(stats.selected_character_id) + + def test_selection_reflected_in_list(self): + UserStats.objects.create(user_id=self.user.id, xp=10) + self._select(self.owl.id) + + response = self._list() + + by_slug = {c['slug']: c for c in response.data['characters']} + self.assertTrue(by_slug['owl']['selected']) + self.assertFalse(by_slug['fox']['selected']) diff --git a/progress/urls.py b/progress/urls.py index 9279d27..e9f87e4 100644 --- a/progress/urls.py +++ b/progress/urls.py @@ -1,10 +1,19 @@ from django.urls import path -from .views import LeaderboardView, StreakCalendarView, UserHeartsView, UserSummaryView +from .views import ( + CharacterListView, + LeaderboardView, + SelectCharacterView, + 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'), + path('characters', CharacterListView.as_view(), name='character-list'), + path('characters/select', SelectCharacterView.as_view(), name='character-select'), ] diff --git a/progress/views.py b/progress/views.py index 084a43a..1037e73 100644 --- a/progress/views.py +++ b/progress/views.py @@ -1,11 +1,13 @@ +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.exceptions import ValidationError from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.throttling import ScopedRateThrottle from rest_framework.views import APIView -from .models import LeaderboardEntry, StreakRecord, UserHearts, UserStats +from .models import Character, LeaderboardEntry, StreakRecord, UserHearts, UserStats class GuestAwareReadThrottle(ScopedRateThrottle): @@ -22,7 +24,10 @@ class GuestAwareReadThrottle(ScopedRateThrottle): ident = self.get_ident(request) return self.cache_format % {'scope': self.scope, 'ident': ident} from .serializers import ( + CharacterListResponseSerializer, LeaderboardResponseSerializer, + SelectCharacterRequestSerializer, + SelectCharacterResponseSerializer, StreakCalendarResponseSerializer, UserHeartsResponseSerializer, UserSummaryResponseSerializer, @@ -137,3 +142,65 @@ class UserHeartsView(APIView): 'isUnlimited': unlimited, } return Response(UserHeartsResponseSerializer(data).data) + + +class CharacterListView(APIView): + """ + GET /api/characters — کاتالوگِ کاراکترهای گیمیفیکیشن (مثلِ ماسکات‌های + Duolingo)، به‌همراه این‌که کدوم برای کاربرِ فعلی باز شده (unlock_xp <= XP + کاربر) و کدوم انتخاب‌شده‌ست. + """ + + permission_classes = [IsAuthenticated] + throttle_scope = 'read' + throttle_classes = [GuestAwareReadThrottle] + + @extend_schema(responses=CharacterListResponseSerializer, tags=['Progress & Gamification (progress)']) + def get(self, request): + stats = UserStats.objects.filter(user_id=request.user.id).first() + xp = stats.xp if stats else 0 + selected_id = stats.selected_character_id if stats else None + + data = { + 'characters': [ + { + 'characterId': str(c.id), + 'slug': c.slug, + 'name': c.name, + 'icon': c.icon, + 'unlockXp': c.unlock_xp, + 'unlocked': xp >= c.unlock_xp, + 'selected': c.id == selected_id, + } + for c in Character.objects.all() + ], + } + return Response(CharacterListResponseSerializer(data).data) + + +class SelectCharacterView(APIView): + """POST /api/characters/select — انتخابِ کاراکترِ فعال؛ فقط کاراکترِ باز‌شده قابلِ انتخابه.""" + + permission_classes = [IsAuthenticated] + throttle_scope = 'user_profile_write' + throttle_classes = [ScopedRateThrottle] + + @extend_schema( + request=SelectCharacterRequestSerializer, + responses=SelectCharacterResponseSerializer, + tags=['Progress & Gamification (progress)'], + ) + def post(self, request): + serializer = SelectCharacterRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + character = get_object_or_404(Character, pk=serializer.validated_data['characterId']) + + stats, _ = UserStats.objects.get_or_create(user_id=request.user.id) + if stats.xp < character.unlock_xp: + raise ValidationError('این کاراکتر هنوز باز نشده است.') + + stats.selected_character_id = character.id + stats.save(update_fields=['selected_character_id']) + + data = {'characterId': str(character.id), 'selected': True} + return Response(SelectCharacterResponseSerializer(data).data)