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 from .services import effective_current_streak 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, ) def _active_plan_title(user_id): from django.db.models import Q from django.utils import timezone from subscription.models import Subscription sub = ( Subscription.objects.select_related('plan') .filter(user_id=user_id, status='active') .filter(Q(expires_at__isnull=True) | Q(expires_at__gt=timezone.now())) .first() ) return sub.plan.title if sub and sub.plan else None 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': effective_current_streak(streak), 'longest': streak.longest_streak if streak else 0, }, # عنوانِ پلنِ اشتراکِ فعال، یا null. تا ۲۰۲۶-۰۹-۲۴ همیشه null بود # (از زمانی که اپِ subscription هنوز وجود نداشت). 'subscription': _active_plan_title(user.id), } 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': effective_current_streak(streak), '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) # کوتاه تا XPی تازه زود دیده شود، بلند تا هزار کاربرِ هم‌زمان هر بار جدول را # از نو حساب نکنند. LEADERBOARD_CACHE_TTL = 60 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') # زنده از فعالیتِ واقعی، با کشِ کوتاه (progress/leaderboard.py). قبلاً از # جدولِ LeaderboardEntry خوانده می‌شد که فقط با اجرای دستیِ # rebuild_leaderboard پر می‌شد، پس XPی کاربرِ تازه هیچ‌وقت دیده نمی‌شد. from django.core.cache import cache from .leaderboard import compute_scores scores = cache.get_or_set( f'leaderboard:{period}', lambda: compute_scores(period), LEADERBOARD_CACHE_TTL, ) entries = [ LeaderboardEntry(user_id=uid, period=period, xp_in_period=xp, rank=rank) for rank, (uid, xp) in enumerate(scores, 1) ] # نام کاربر از 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=UserHearts.REGEN_FIELDS) 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, 'fullRefillAt': None if unlimited else hearts.full_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)