add gamification characters (Duolingo-style mascot catalog)
New progress.Character model: an admin-managed catalog (same pattern as subscription.Plan) of selectable mascots, each unlocked once the user's XP crosses unlock_xp. No separate ownership table yet — unlocking is a pure function of UserStats.xp, kept intentionally minimal since this is the first of an incremental rollout the senior asked for. - GET /api/characters — catalog with per-user unlocked/selected flags - POST /api/characters/select — picks an active character, rejecting ones the user hasn't unlocked yet - UserStats.selected_character_id tracks the pick - seed_test_data now seeds 4 sample characters (owl/fox/turtle/lion) for local/staging testing Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -33,7 +33,7 @@ API docs: Swagger `/api/docs/`, Redoc `/api/redoc/`, admin `/admin/`.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `accounts` | identity, sessions, OTP auth | `User`, `Profile`, `Session`, `OTPCode` |
|
| `accounts` | identity, sessions, OTP auth | `User`, `Profile`, `Session`, `OTPCode` |
|
||||||
| `content` | lessons/curriculum | `Grade`, `Unit`, `Node`, `Lesson`, `Question`, `Choice`, `NodeProgress` |
|
| `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` |
|
| `ai_tutor` | AI assistant chat | `Conversation`, `Message`, `MessageFeedback` |
|
||||||
| `subscription` | plans | `Plan`, `Subscription` |
|
| `subscription` | plans | `Plan`, `Subscription` |
|
||||||
| `payment` | orders/transactions (Zarinpal gateway + Cafe Bazaar IAP) | `Order`, `Transaction` |
|
| `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.
|
**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.
|
**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)
|
## Known gaps (per README pre-deploy checklist)
|
||||||
|
|||||||
@@ -134,6 +134,9 @@ class Command(BaseCommand):
|
|||||||
BugReport.objects.filter(user_id__in=user_ids).delete()
|
BugReport.objects.filter(user_id__in=user_ids).delete()
|
||||||
FamilyLink.objects.filter(parent_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()
|
Plan.objects.filter(title__startswith=SEED_TAG).delete()
|
||||||
DiscountCode.objects.filter(code='SEEDTEST10').delete()
|
DiscountCode.objects.filter(code='SEEDTEST10').delete()
|
||||||
Offer.objects.filter(code='SEEDTRIAL7').delete()
|
Offer.objects.filter(code='SEEDTRIAL7').delete()
|
||||||
@@ -153,6 +156,7 @@ class Command(BaseCommand):
|
|||||||
def _seed(self):
|
def _seed(self):
|
||||||
main_user, guest_user, parent_user, child_user, referred_user = self._seed_accounts()
|
main_user, guest_user, parent_user, child_user, referred_user = self._seed_accounts()
|
||||||
self._seed_content_and_progress(main_user)
|
self._seed_content_and_progress(main_user)
|
||||||
|
self._seed_characters()
|
||||||
self._seed_ai_tutor(main_user)
|
self._seed_ai_tutor(main_user)
|
||||||
plan_monthly, plan_yearly = self._seed_subscription_and_payment(main_user)
|
plan_monthly, plan_yearly = self._seed_subscription_and_payment(main_user)
|
||||||
self._seed_discount(main_user)
|
self._seed_discount(main_user)
|
||||||
@@ -303,6 +307,26 @@ class Command(BaseCommand):
|
|||||||
'content/progress: 1 پایه، 1 مبحث، 2 Node، 2 درس، 2 سوال و پیشرفتِ کاربرِ اصلی آماده شد.'
|
'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 -------------------------------------------------------------
|
# -- ai_tutor -------------------------------------------------------------
|
||||||
def _seed_ai_tutor(self, main_user):
|
def _seed_ai_tutor(self, main_user):
|
||||||
from ai_tutor.models import Conversation, Message, MessageFeedback
|
from ai_tutor.models import Conversation, Message, MessageFeedback
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
from django.contrib import admin
|
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)
|
@admin.register(UserStats)
|
||||||
class UserStatsAdmin(admin.ModelAdmin):
|
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)
|
@admin.register(LeaderboardEntry)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -11,6 +11,10 @@ class UserStats(models.Model):
|
|||||||
xp = models.PositiveIntegerField(default=0)
|
xp = models.PositiveIntegerField(default=0)
|
||||||
coins = models.PositiveIntegerField(default=0)
|
coins = models.PositiveIntegerField(default=0)
|
||||||
progress_percent = models.FloatField(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):
|
def __str__(self):
|
||||||
return f"stats-user{self.user_id}"
|
return f"stats-user{self.user_id}"
|
||||||
@@ -92,3 +96,27 @@ class UserHearts(models.Model):
|
|||||||
self.current_hearts -= 1
|
self.current_hearts -= 1
|
||||||
if self.next_refill_at is None and self.current_hearts < self.max_hearts:
|
if self.next_refill_at is None and self.current_hearts < self.max_hearts:
|
||||||
self.next_refill_at = timezone.now() + HEART_REFILL_INTERVAL
|
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
|
||||||
|
|||||||
@@ -40,3 +40,26 @@ class UserHeartsResponseSerializer(serializers.Serializer):
|
|||||||
maxHearts = serializers.IntegerField()
|
maxHearts = serializers.IntegerField()
|
||||||
nextRefillAt = serializers.DateTimeField(allow_null=True)
|
nextRefillAt = serializers.DateTimeField(allow_null=True)
|
||||||
isUnlimited = serializers.BooleanField()
|
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()
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from rest_framework.test import APIClient
|
|||||||
from accounts.authentication import issue_access_token
|
from accounts.authentication import issue_access_token
|
||||||
from accounts.models import User
|
from accounts.models import User
|
||||||
|
|
||||||
from .models import HEART_REFILL_INTERVAL, UserHearts
|
from .models import HEART_REFILL_INTERVAL, Character, UserHearts, UserStats
|
||||||
|
|
||||||
|
|
||||||
class UserHeartsRegenTests(TestCase):
|
class UserHeartsRegenTests(TestCase):
|
||||||
@@ -60,3 +60,72 @@ class UserHeartsRegenTests(TestCase):
|
|||||||
response = self._get_hearts()
|
response = self._get_hearts()
|
||||||
self.assertEqual(response.data['currentHearts'], 5)
|
self.assertEqual(response.data['currentHearts'], 5)
|
||||||
self.assertIsNone(response.data['nextRefillAt'])
|
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'])
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import LeaderboardView, StreakCalendarView, UserHeartsView, UserSummaryView
|
from .views import (
|
||||||
|
CharacterListView,
|
||||||
|
LeaderboardView,
|
||||||
|
SelectCharacterView,
|
||||||
|
StreakCalendarView,
|
||||||
|
UserHeartsView,
|
||||||
|
UserSummaryView,
|
||||||
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('user/summary', UserSummaryView.as_view(), name='user-summary'),
|
path('user/summary', UserSummaryView.as_view(), name='user-summary'),
|
||||||
path('user/streak/calendar', StreakCalendarView.as_view(), name='streak-calendar'),
|
path('user/streak/calendar', StreakCalendarView.as_view(), name='streak-calendar'),
|
||||||
path('leaderboard', LeaderboardView.as_view(), name='leaderboard'),
|
path('leaderboard', LeaderboardView.as_view(), name='leaderboard'),
|
||||||
path('user/hearts', UserHeartsView.as_view(), name='user-hearts'),
|
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'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
from django.shortcuts import get_object_or_404
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.throttling import ScopedRateThrottle
|
from rest_framework.throttling import ScopedRateThrottle
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from .models import LeaderboardEntry, StreakRecord, UserHearts, UserStats
|
from .models import Character, LeaderboardEntry, StreakRecord, UserHearts, UserStats
|
||||||
|
|
||||||
|
|
||||||
class GuestAwareReadThrottle(ScopedRateThrottle):
|
class GuestAwareReadThrottle(ScopedRateThrottle):
|
||||||
@@ -22,7 +24,10 @@ class GuestAwareReadThrottle(ScopedRateThrottle):
|
|||||||
ident = self.get_ident(request)
|
ident = self.get_ident(request)
|
||||||
return self.cache_format % {'scope': self.scope, 'ident': ident}
|
return self.cache_format % {'scope': self.scope, 'ident': ident}
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
|
CharacterListResponseSerializer,
|
||||||
LeaderboardResponseSerializer,
|
LeaderboardResponseSerializer,
|
||||||
|
SelectCharacterRequestSerializer,
|
||||||
|
SelectCharacterResponseSerializer,
|
||||||
StreakCalendarResponseSerializer,
|
StreakCalendarResponseSerializer,
|
||||||
UserHeartsResponseSerializer,
|
UserHeartsResponseSerializer,
|
||||||
UserSummaryResponseSerializer,
|
UserSummaryResponseSerializer,
|
||||||
@@ -137,3 +142,65 @@ class UserHeartsView(APIView):
|
|||||||
'isUnlimited': unlimited,
|
'isUnlimited': unlimited,
|
||||||
}
|
}
|
||||||
return Response(UserHeartsResponseSerializer(data).data)
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user