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:
2026-09-06 13:53:17 +03:30
parent b5476e1c5d
commit 37a1741e0e
9 changed files with 266 additions and 6 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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),
),
]

View File

@@ -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

View File

@@ -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()

View File

@@ -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'])

View File

@@ -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'),
]

View File

@@ -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)