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