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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user