Files
MathApp/progress/views.py
Zareian a275eac7fd fix heart refill and grant unlimited hearts to active subscribers
next_refill_at was set on UserHearts but never read or recalculated,
so a heart lost to zero never came back. UserHearts.apply_regen() now
catches up missed 24h intervals on read/deduct, and lose_heart() starts
the timer only when it isn't already running.

Any user with an active (non-expired) subscription now skips heart
deduction entirely, regardless of which plan they're on — good enough
for now until per-plan perks are needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 13:26:49 +03:30

140 lines
5.5 KiB
Python

from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
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
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 (
LeaderboardResponseSerializer,
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)