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, StreakRecord, UserHearts, UserStats from .services import effective_current_streak, streak_state from .serializers import ( CharacterListResponseSerializer, LeaderboardResponseSerializer, SelectCharacterRequestSerializer, SelectCharacterResponseSerializer, StreakCalendarResponseSerializer, UserHeartsResponseSerializer, UserSummaryResponseSerializer, WeeklyReportResponseSerializer, ) 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 = [ScopedRateThrottle] @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() current, active_today, lost = streak_state(streak) 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': current, 'longest': streak.longest_streak if streak else 0, 'activeToday': active_today, 'lost': lost, }, # عنوانِ پلنِ اشتراکِ فعال، یا 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 = [ScopedRateThrottle] @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) class WeeklyReportView(APIView): """ GET /api/user/weekly-report — گزارشِ ۷ روزِ اخیرِ خودِ کاربر در کنارِ ۷ روزِ قبلش. فرانت آن را با عنوانِ «گزارشِ والدین» نشان می‌دهد؛ والد همین را در حسابِ خودِ دانش‌آموز می‌بیند و حسابِ جدا ندارد. فقط دیتای خودِ درخواست‌دهنده را برمی‌گرداند. محاسبه و این‌که `minutes` تخمینی است: `progress/report.py`. """ permission_classes = [IsAuthenticated] throttle_scope = 'read' throttle_classes = [ScopedRateThrottle] @extend_schema(responses=WeeklyReportResponseSerializer, tags=['Progress & Gamification (progress)']) def get(self, request): from .report import weekly_report return Response(WeeklyReportResponseSerializer(weekly_report(request.user.id)).data) class LeaderboardView(APIView): """ GET /api/leaderboard?period= — رتبه‌بندی هفتگی/ماهانه فقط برای کاربرِ واردشده (مهمان هم توکن دارد): پاسخ نامِ کاربران، که بیشترشان کودک‌اند، و شناسه‌ی داخلی‌شان را دارد و بدونِ ورود برای هر کسی در اینترنت قابلِ جمع‌آوری بود. """ permission_classes = [IsAuthenticated] throttle_scope = 'read' throttle_classes = [ScopedRateThrottle] @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') from .leaderboard import PERIODS if period not in PERIODS: # پیش‌تر هر رشته‌ای ۲۰۰ و یک کلیدِ کشِ تازه می‌ساخت. raise ValidationError({'period': f'یکی از {", ".join(PERIODS)}.'}) # زنده از فعالیتِ واقعی، با کشِ کوتاه (progress/leaderboard.py). قبلاً از # جدولِ LeaderboardEntry خوانده می‌شد که فقط با اجرای دستیِ # rebuild_leaderboard پر می‌شد، پس XPی کاربرِ تازه هیچ‌وقت دیده نمی‌شد. from .leaderboard import cached_scores scores = cached_scores(period) # نام کاربر از accounts.User خونده می‌شه (فقط برای نمایش، بدون FK واقعی). from accounts.models import User as AccountUser names = dict( AccountUser.objects.filter(id__in=[uid for uid, _ in scores]).values_list('id', 'name') ) data = { 'period': period, 'entries': [ { 'rank': rank, 'userId': str(uid), 'name': names.get(uid, ''), 'xpInPeriod': xp, } for rank, (uid, xp) in enumerate(scores, 1) ], } return Response(LeaderboardResponseSerializer(data).data) class UserHeartsView(APIView): """GET /api/user/hearts — وضعیت فعلی جان‌ها (هارت)""" permission_classes = [IsAuthenticated] throttle_scope = 'read' throttle_classes = [ScopedRateThrottle] @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 = [ScopedRateThrottle] @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_id = serializer.validated_data['characterId'] if not (character_id.isascii() and character_id.isdigit()): raise ValidationError({'characterId': 'شناسه‌ی کاراکتر باید عدد باشد.'}) character = get_object_or_404(Character, pk=int(character_id)) 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)