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>
207 lines
8.2 KiB
Python
207 lines
8.2 KiB
Python
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 Character, LeaderboardEntry, StreakRecord, UserHearts, UserStats
|
|
|
|
|
|
class GuestAwareReadThrottle(ScopedRateThrottle):
|
|
"""همون الگوی GuestAwareContentThrottle: کاربرِ مهمان (هر deviceId یک pk
|
|
تازه) با IP کلید میخوره تا چرخشِ حساب سقفِ read رو دور نزنه."""
|
|
|
|
def get_cache_key(self, request, view):
|
|
user = request.user
|
|
if user and user.is_authenticated and getattr(user, 'is_guest', False):
|
|
ident = f'guest-ip:{self.get_ident(request)}'
|
|
elif user and user.is_authenticated:
|
|
ident = user.pk
|
|
else:
|
|
ident = self.get_ident(request)
|
|
return self.cache_format % {'scope': self.scope, 'ident': ident}
|
|
from .serializers import (
|
|
CharacterListResponseSerializer,
|
|
LeaderboardResponseSerializer,
|
|
SelectCharacterRequestSerializer,
|
|
SelectCharacterResponseSerializer,
|
|
StreakCalendarResponseSerializer,
|
|
UserHeartsResponseSerializer,
|
|
UserSummaryResponseSerializer,
|
|
)
|
|
|
|
|
|
class UserSummaryView(APIView):
|
|
"""GET /api/user/summary — پروفایل + آمار + استریک (ادغامشده)"""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
throttle_scope = 'read'
|
|
throttle_classes = [GuestAwareReadThrottle]
|
|
|
|
@extend_schema(responses=UserSummaryResponseSerializer, tags=['Progress & Gamification (progress)'])
|
|
def get(self, request):
|
|
user = request.user # accounts.User، از طریق TadrisinoTokenAuthentication
|
|
stats = UserStats.objects.filter(user_id=user.id).first()
|
|
streak = StreakRecord.objects.filter(user_id=user.id).first()
|
|
|
|
data = {
|
|
'userId': str(user.id),
|
|
'name': user.name,
|
|
'avatar': user.avatar,
|
|
'gradeId': user.grade_id,
|
|
'xp': stats.xp if stats else 0,
|
|
'coins': stats.coins if stats else 0,
|
|
'streak': {
|
|
'current': streak.current_streak if streak else 0,
|
|
'longest': streak.longest_streak if streak else 0,
|
|
},
|
|
# اپ subscription هنوز در این فاز پیادهسازی نشده؛ فعلاً null.
|
|
'subscription': None,
|
|
}
|
|
return Response(UserSummaryResponseSerializer(data).data)
|
|
|
|
|
|
class StreakCalendarView(APIView):
|
|
"""GET /api/user/streak/calendar — تقویم کامل روزهای فعال"""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
throttle_scope = 'read'
|
|
throttle_classes = [GuestAwareReadThrottle]
|
|
|
|
@extend_schema(responses=StreakCalendarResponseSerializer, tags=['Progress & Gamification (progress)'])
|
|
def get(self, request):
|
|
user = request.user
|
|
streak = StreakRecord.objects.filter(user_id=user.id).first()
|
|
|
|
data = {
|
|
'currentStreak': streak.current_streak if streak else 0,
|
|
'longestStreak': streak.longest_streak if streak else 0,
|
|
'activeDays': list(streak.active_days.values_list('date', flat=True)) if streak else [],
|
|
}
|
|
return Response(StreakCalendarResponseSerializer(data).data)
|
|
|
|
|
|
class LeaderboardView(APIView):
|
|
"""GET /api/leaderboard?period= — رتبهبندی هفتگی/ماهانه"""
|
|
|
|
throttle_scope = 'read'
|
|
throttle_classes = [GuestAwareReadThrottle]
|
|
|
|
@extend_schema(
|
|
parameters=[OpenApiParameter('period', OpenApiTypes.STR, required=False,
|
|
enum=['weekly', 'all_time'])],
|
|
responses=LeaderboardResponseSerializer,
|
|
tags=['Progress & Gamification (progress)'],
|
|
)
|
|
def get(self, request):
|
|
period = request.query_params.get('period', 'weekly')
|
|
entries = LeaderboardEntry.objects.filter(period=period).order_by('rank')[:50]
|
|
|
|
# نام کاربر از accounts.User خونده میشه (فقط برای نمایش، بدون FK واقعی).
|
|
from accounts.models import User as AccountUser
|
|
user_ids = [e.user_id for e in entries]
|
|
names = {u.id: u.name for u in AccountUser.objects.filter(id__in=user_ids)}
|
|
|
|
data = {
|
|
'period': period,
|
|
'entries': [
|
|
{
|
|
'rank': e.rank,
|
|
'userId': str(e.user_id),
|
|
'name': names.get(e.user_id, ''),
|
|
'xpInPeriod': e.xp_in_period,
|
|
}
|
|
for e in entries
|
|
],
|
|
}
|
|
return Response(LeaderboardResponseSerializer(data).data)
|
|
|
|
|
|
class UserHeartsView(APIView):
|
|
"""GET /api/user/hearts — وضعیت فعلی جانها (هارت)"""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
@extend_schema(responses=UserHeartsResponseSerializer, tags=['Progress & Gamification (progress)'])
|
|
def get(self, request):
|
|
from subscription.services import has_unlimited_hearts
|
|
|
|
user = request.user
|
|
hearts, _ = UserHearts.objects.get_or_create(user_id=user.id)
|
|
if hearts.apply_regen():
|
|
hearts.save(update_fields=['current_hearts', 'next_refill_at'])
|
|
|
|
unlimited = has_unlimited_hearts(user.id)
|
|
data = {
|
|
'currentHearts': hearts.max_hearts if unlimited else hearts.current_hearts,
|
|
'maxHearts': hearts.max_hearts,
|
|
'nextRefillAt': None if unlimited else hearts.next_refill_at,
|
|
'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)
|