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

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