accounts.0008 drops the columns and refuses to run while guest rows exist. Docs describe the current state; tests that only proved guest behaviour are gone. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
257 lines
11 KiB
Python
257 lines
11 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, StreakRecord, UserHearts, UserStats
|
||
from .services import effective_current_streak
|
||
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()
|
||
|
||
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 = [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)
|
||
|
||
|
||
# کوتاه تا XPی تازه زود دیده شود، بلند تا هزار کاربرِ همزمان هر بار جدول را
|
||
# از نو حساب نکنند.
|
||
LEADERBOARD_CACHE_TTL = 60
|
||
|
||
|
||
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 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,
|
||
)
|
||
# نام کاربر از 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]
|
||
|
||
@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)
|