from django.core.cache import cache from django.db import IntegrityError, transaction from django.http import Http404 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 import status from rest_framework.exceptions import NotFound, PermissionDenied, 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 progress.models import UserHearts, UserStats from subscription.services import has_unlimited_hearts from .models import ( Choice, Grade, Lesson, NodeProgress, Question, QuestionReport, UserAnswer, UserLessonCompletion, ) from .serializers import ( AnswerRequestSerializer, AnswerResponseSerializer, GradesListResponseSerializer, HomeResponseSerializer, LessonCompleteRequestSerializer, LessonCompleteResponseSerializer, LessonIntroResponseSerializer, LessonQuestionsResponseSerializer, NodeProgressListResponseSerializer, QuestionReportRequestSerializer, QuestionReportResponseSerializer, ) class GuestAwareContentThrottle(ScopedRateThrottle): """ مشترک با GuestAwareAIThrottle/GuestAwareFamilyThrottle/GuestAwareDiscountThrottle و مشابه‌هاش: چون GuestSessionView با هر deviceId جدید یک pk تازه می‌سازه، throttleِ استانداردِ بر اساسِ user.pk با چرخشِ حسابِ مهمان دور زده می‌شه — درحالی‌که answer/complete اثرِ جانبیِ ماندگار (هارت، xp/coins/progress) دارن. برای همین فقط کاربرهای مهمان با IP کلید می‌خورن؛ کاربرهای غیرمهمان مثلِ قبل بر اساسِ pk. """ 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} # --- کشِ داده‌ی شبهِ‌ثابتِ برنامه‌ی درسی ------------------------------------- # گریدها و درختِ unit/node/lesson تقریباً هیچ‌وقت عوض نمی‌شوند ولی روی هر بار # باز شدنِ اپ خوانده می‌شوند. یک نسخه‌کلیدِ مشترک نگه می‌داریم که با هر تغییرِ # محتوا (سیگنال در content/receivers.py) یک واحد جلو می‌رود و همه‌ی کلیدهای # کشِ محتوا را یک‌جا باطل می‌کند. دیتای per-user (پیشرفت/آمار) کش نمی‌شود. _CURRICULUM_VERSION_KEY = 'content:curriculum:ver' _CACHE_TTL = 3600 def curriculum_version() -> int: return cache.get_or_set(_CURRICULUM_VERSION_KEY, 1, None) or 1 def bump_curriculum_version() -> None: try: cache.incr(_CURRICULUM_VERSION_KEY) except ValueError: cache.set(_CURRICULUM_VERSION_KEY, curriculum_version() + 1, None) def _grades_payload() -> dict: key = f'content:grades:v{curriculum_version()}' def build(): return {'grades': [ {'id': g.id, 'title': g.title, 'icon': g.icon, 'color': g.color} for g in Grade.objects.all().order_by('id') ]} return cache.get_or_set(key, build, _CACHE_TTL) def _grade_tree(grade_id: int): """اسکلتِ گریدِ [{id,title,nodes:[{id,title,lesson_ids}]}] — بدونِ status/دیتای کاربر. None یعنی گریدِ ناموجود؛ [] یعنی گریدِ موجودِ بدونِ واحد.""" key = f'content:tree:grade:{grade_id}:v{curriculum_version()}' cached = cache.get(key) if cached is not None: return None if cached is False else cached if not Grade.objects.filter(pk=grade_id).exists(): cache.set(key, False, _CACHE_TTL) return None units = ( Grade.objects.get(pk=grade_id) .units.prefetch_related('nodes__lessons').all() ) tree = [ { 'id': str(unit.id), 'title': unit.title, 'nodes': [ { 'id': str(node.id), 'title': node.title, 'lesson_ids': [lsn.id for lsn in node.lessons.all()], } for node in unit.nodes.all() ], } for unit in units ] cache.set(key, tree, _CACHE_TTL) return tree class GradesListView(APIView): """GET /api/grades — لیست پایه‌ها""" throttle_scope = 'read' throttle_classes = [GuestAwareContentThrottle] @extend_schema(responses=GradesListResponseSerializer, tags=['Curriculum (content)']) def get(self, request): return Response(GradesListResponseSerializer(_grades_payload()).data) def _node_status(lesson_ids, completed_ids, is_first_unlocked_candidate): if not lesson_ids: return 'locked' done = sum(1 for lid in lesson_ids if lid in completed_ids) if done >= len(lesson_ids): return 'completed' if done > 0 or is_first_unlocked_candidate: return 'unlocked' return 'locked' class HomeView(APIView): """GET /api/home?grade — نقشه‌ی مهارت‌ها + آمار (ادغام‌شده)""" # userId همیشه از request.user.id (توکن معتبر) گرفته می‌شود، نه ورودی کلاینت (IDOR). permission_classes = [IsAuthenticated] throttle_scope = 'read' throttle_classes = [GuestAwareContentThrottle] @extend_schema( parameters=[ OpenApiParameter('grade', OpenApiTypes.INT, required=False), ], responses=HomeResponseSerializer, tags=['Curriculum (content)'], ) def get(self, request): user_id = request.user.id # ⚠️ پیش‌تر فقط پارامترِ صریحِ ?grade= خوانده می‌شد و پایه‌ی ذخیره‌شده‌ی # خودِ کاربر نادیده می‌ماند؛ نتیجه این بود که اپ در «آشنایی» پایه را ثبت # می‌کرد و بعد صفحه‌ی خانه خالی برمی‌گشت. حالا پارامتر اولویت دارد (برای # مرورِ پایه‌ی دیگر) و در نبودش پایه‌ی خودِ کاربر ملاک است. grade_param = request.query_params.get('grade') if grade_param: try: grade_id = int(grade_param) except (TypeError, ValueError): raise Http404 else: grade_id = request.user.grade_id tree = [] if grade_id: tree = _grade_tree(grade_id) if tree is None: # پایه‌ای که کاربر ذخیره کرده ممکن است بعداً حذف شده باشد؛ آن # حالت خطا نیست، فقط نقشه‌ی خالی است. ولی ?grade=ی که کاربر # صریحاً خواسته و وجود ندارد، ۴۰۴ است. if grade_param: raise Http404 tree = [] # همه‌ی lesson_idهای کامل‌شده‌ی این کاربر در همین گرید — یک کوئری، # مستقل از تعدادِ node (رفعِ N+1). all_lesson_ids = [lid for unit in tree for node in unit['nodes'] for lid in node['lesson_ids']] completed_ids = set() if all_lesson_ids: completed_ids = set( UserLessonCompletion.objects.filter( user_id=user_id, lesson_id__in=all_lesson_ids ).values_list('lesson_id', flat=True) ) units_payload = [] for unit in tree: node_payload = [] unlock_next = True # اولین نودِ قفل‌نشده در هر واحد، باز می‌مونه for node in unit['nodes']: node_status = _node_status(node['lesson_ids'], completed_ids, unlock_next) if node_status != 'completed': unlock_next = False node_payload.append({ 'id': node['id'], 'title': node['title'], 'status': node_status, 'lessonIds': node['lesson_ids'], }) units_payload.append({'id': unit['id'], 'title': unit['title'], 'nodes': node_payload}) stats = UserStats.objects.filter(user_id=user_id).first() stats_payload = { 'progressPercent': stats.progress_percent if stats else 0, 'xp': stats.xp if stats else 0, 'coins': stats.coins if stats else 0, } data = {'units': units_payload, 'stats': stats_payload} return Response(HomeResponseSerializer(data).data) class LessonIntroView(APIView): """GET /api/lessons/{id}/intro""" @extend_schema(responses=LessonIntroResponseSerializer, tags=['Curriculum (content)']) def get(self, request, lesson_id): lesson = get_object_or_404(Lesson, pk=lesson_id) data = { 'lessonId': str(lesson.id), 'title': lesson.title, 'introText': lesson.intro_text, 'mascotAnimation': lesson.mascot_animation, 'questionCount': lesson.questions.count(), } return Response(LessonIntroResponseSerializer(data).data) class LessonQuestionsView(APIView): """GET /api/lessons/{id}/questions""" @extend_schema(responses=LessonQuestionsResponseSerializer, tags=['Curriculum (content)']) def get(self, request, lesson_id): lesson = get_object_or_404(Lesson, pk=lesson_id) questions = lesson.questions.prefetch_related('choices').all() data = {'questions': [ { 'id': str(q.id), 'type': q.type, 'prompt': q.prompt, 'options': [{'id': str(c.id), 'value': c.value} for c in q.choices.all()], } for q in questions ]} return Response(LessonQuestionsResponseSerializer(data).data) class QuestionAnswerView(APIView): """POST /api/questions/{id}/answer — ثبت پاسخ""" # userId همیشه از request.user.id گرفته می‌شود، نه ورودی کلاینت. permission_classes = [IsAuthenticated] # Idempotency-Key فقط جلوی retry با همون کلید رو می‌گیره، نه حجمِ درخواست‌های # *متفاوت* که هرکدوم UserAnswer جدید می‌سازن و هارت/NodeProgress رو تغییر می‌دن؛ # برای همون GuestAwareContentThrottle لازمه (نگاه کنید به تعریفش بالا). throttle_scope = 'question_answer' throttle_classes = [GuestAwareContentThrottle] @extend_schema( request=AnswerRequestSerializer, responses=AnswerResponseSerializer, parameters=[ OpenApiParameter( 'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False, description=( 'کلید اختیاری idempotency (مثل payment و lesson/complete). اگه ست ' 'بشه، رتری/دابل‌کلیکِ همون submit با همون کلید، دوباره ردیفِ پاسخ/' 'کسرِ هارتِ تکراری نمی‌سازه و نتیجه‌ی همون پاسخِ قبلاً ثبت‌شده ' 'برگردونده می‌شه.' ), ), ], tags=['Curriculum (content)'], ) def post(self, request, question_id): question = get_object_or_404(Question, pk=question_id) serializer = AnswerRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) selected_option_id = serializer.validated_data['selectedOptionId'] choice = get_object_or_404(Choice, pk=selected_option_id, question=question) correct_choice = question.choices.filter(is_correct=True).first() user_id = request.user.id xp_earned = 10 if choice.is_correct else 0 idempotency_key = request.headers.get('Idempotency-Key') or None # درخواست تکراری با همین کلید: نتیجه‌ی قبلی بدون کسر دوباره‌ی هارت برگردانده می‌شود. if idempotency_key: existing = UserAnswer.objects.filter( user_id=user_id, question=question, idempotency_key=idempotency_key, ).first() if existing is not None: hearts = UserHearts.objects.filter(user_id=user_id).first() data = { 'correct': existing.correct, 'correctOptionId': str(correct_choice.id) if correct_choice else '', 'xpEarned': existing.xp_earned, 'heartsRemaining': hearts.current_hearts if hearts else 5, 'mascotMood': 'happy' if existing.correct else 'encourage', } return Response(AnswerResponseSerializer(data).data) # اتمیک: ثبت پاسخ + کسر هارت باید یک واحد باشند. UserHearts زیر قفل # خوانده/کم می‌شود تا lost update در پاسخ‌های هم‌زمان رخ ندهد. قید یکتاییِ # سطح DB (uniq_user_answer_idempotency_key) جلوی ثبت دوباره با همان کلید را می‌گیرد. try: with transaction.atomic(): UserAnswer.objects.create( user_id=user_id, question=question, choice=choice, correct=choice.is_correct, xp_earned=xp_earned, idempotency_key=idempotency_key, ) if not choice.is_correct: UserHearts.objects.get_or_create(user_id=user_id) hearts = UserHearts.objects.select_for_update().get(user_id=user_id) hearts.apply_regen() if not has_unlimited_hearts(user_id): hearts.lose_heart() hearts.save(update_fields=['current_hearts', 'next_refill_at']) hearts_remaining = hearts.current_hearts else: hearts = UserHearts.objects.filter(user_id=user_id).first() hearts_remaining = hearts.current_hearts if hearts else 5 except IntegrityError: if not idempotency_key: raise existing = UserAnswer.objects.filter( user_id=user_id, question=question, idempotency_key=idempotency_key, ).first() if existing is None: raise choice = existing.choice xp_earned = existing.xp_earned # قفل ردیف UserHearts هم اینجا رعایت می‌شود تا این خواندنِ fallback با تغییرات هم‌زمان race نکند. with transaction.atomic(): hearts = UserHearts.objects.select_for_update().filter(user_id=user_id).first() hearts_remaining = hearts.current_hearts if hearts else 5 data = { 'correct': choice.is_correct, 'correctOptionId': str(correct_choice.id) if correct_choice else '', 'xpEarned': xp_earned, 'heartsRemaining': hearts_remaining if hearts_remaining is not None else 5, 'mascotMood': 'happy' if choice.is_correct else 'encourage', } return Response(AnswerResponseSerializer(data).data) class LessonCompleteView(APIView): """POST /api/lessons/{id}/complete — اتمام درس + آمار جدید""" # userId همیشه از request.user.id گرفته می‌شود (نه ورودیِ کلاینت)، وگرنه کلاینت # می‌تونست به‌جای هر کاربرِ دیگری xp/coins ثبت کنه. permission_classes = [IsAuthenticated] # Idempotency-Key فقط جلوی retry با همون کلید رو می‌گیره؛ برای همینه که # GuestAwareContentThrottle لازمه (نگاه کنید به تعریفش بالا). نرخ ۱۰/hour # (نه ۶۰/min مثلِ question_answer) چون این endpoint رکوردِ ماندگار می‌سازه. throttle_scope = 'lesson_complete' throttle_classes = [GuestAwareContentThrottle] @extend_schema( request=LessonCompleteRequestSerializer, responses=LessonCompleteResponseSerializer, parameters=[ OpenApiParameter( 'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False, description=( 'کلید اختیاری idempotency (مثل payment). اگه ست بشه، رتری/' 'دابل‌کلیکِ همون submit با همون کلید، دوباره ردیف/جایزه‌ی ' 'تکراری نمی‌سازه و نتیجه‌ی همون completion موجود برگردونده می‌شه.' ), ), ], tags=['Curriculum (content)'], ) def post(self, request, lesson_id): lesson = get_object_or_404(Lesson, pk=lesson_id) serializer = LessonCompleteRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) correct_count = serializer.validated_data['correctCount'] # totalQuestions در بدنه‌ی درخواست پذیرفته می‌شود (سازگاری با schema) اما در محاسبه استفاده نمی‌شود. # correctCount نباید از تعداد واقعی سوال‌های این درس بیشتر باشد، تا xp/coins متورم نگیرد. actual_question_count = lesson.questions.count() if correct_count > actual_question_count: raise ValidationError({ 'correctCount': ( 'correctCount نمی‌تواند از تعداد واقعی سوال‌های این درس ' f'({actual_question_count}) بیشتر باشد.' ) }) # ratio همیشه بر اساس actual_question_count محاسبه می‌شود، نه totalQuestions ارسالی کلاینت. ratio = correct_count / (actual_question_count or 1) stars = 3 if ratio >= 0.9 else 2 if ratio >= 0.6 else 1 if ratio > 0 else 0 potential_xp = correct_count * 10 potential_coins = stars * 5 user_id = request.user.id idempotency_key = request.headers.get('Idempotency-Key') or None # درخواست تکراری با همین کلید: نتیجه‌ی قبلی idempotent برگردانده می‌شود. if idempotency_key: existing = UserLessonCompletion.objects.filter( user_id=user_id, lesson=lesson, idempotency_key=idempotency_key, ).first() if existing is not None: stats = UserStats.objects.filter(user_id=user_id).first() stats_payload = { 'progressPercent': stats.progress_percent if stats else 0, 'xp': stats.xp if stats else 0, 'coins': stats.coins if stats else 0, } data = { 'stars': existing.stars, 'xpEarned': existing.xp_earned, 'coinsEarned': existing.coins_earned, 'stats': stats_payload, } return Response(LessonCompleteResponseSerializer(data).data) # atomic لازمه چون create پایین‌تر سیگنالِ post_save رو تریگر می‌کنه # (NodeProgress) و UserStats هم آپدیت می‌شه؛ بدون اون، fail شدنِ save # روی stats یعنی تکمیل ثبت شده ولی xp/coins یک‌بارمصرف از دست رفته. # ردیفِ UserStats قفل می‌شه (select_for_update) و چکِ is_first_completion # داخلِ همون قفل انجام می‌شه تا دو درخواستِ همزمان (دابل‌کلیک) هردو # جایزه‌ی کاملِ اول رو نگیرن. try: with transaction.atomic(): UserStats.objects.get_or_create(user_id=user_id) stats = UserStats.objects.select_for_update().get(user_id=user_id) is_first_completion = not UserLessonCompletion.objects.filter( user_id=user_id, lesson=lesson ).exists() xp_earned = potential_xp if is_first_completion else 0 coins_earned = potential_coins if is_first_completion else 0 UserLessonCompletion.objects.create( user_id=user_id, lesson=lesson, correct_count=correct_count, stars=stars, xp_earned=xp_earned, coins_earned=coins_earned, idempotency_key=idempotency_key, ) stats.xp += xp_earned stats.coins += coins_earned total_lessons = Lesson.objects.count() or 1 completed_lessons = UserLessonCompletion.objects.filter( user_id=user_id ).values('lesson_id').distinct().count() stats.progress_percent = round(min(completed_lessons / total_lessons, 1.0) * 100, 2) stats.save(update_fields=['xp', 'coins', 'progress_percent']) except IntegrityError: # قید یکتاییِ سطح DB جلوی درخواست‌های هم‌زمان با همان کلید را می‌گیرد؛ # نتیجه‌ی completion رقیب idempotent برگردانده می‌شود. if not idempotency_key: raise existing = UserLessonCompletion.objects.filter( user_id=user_id, lesson=lesson, idempotency_key=idempotency_key, ).first() if existing is None: raise stats = UserStats.objects.filter(user_id=user_id).first() stars = existing.stars xp_earned = existing.xp_earned coins_earned = existing.coins_earned stats_payload = { 'progressPercent': stats.progress_percent if stats else 0, 'xp': stats.xp if stats else 0, 'coins': stats.coins if stats else 0, } data = { 'stars': stars, 'xpEarned': xp_earned, 'coinsEarned': coins_earned, 'stats': stats_payload, } return Response(LessonCompleteResponseSerializer(data).data) class NodeProgressListView(APIView): """ GET /api/progress/nodes?userId=&unit= — لیست وضعیتِ کاربر روی هر Node. توکن اجباری است. userId قابل قبول است (والد می‌تواند پیشرفت فرزندش را ببیند)، اما فقط اگر برابر خودِ request.user باشد یا درخواست‌دهنده والدِ تأییدشده‌ی همان کاربر باشد (FamilyLink با status='accepted')؛ در غیر این صورت 403. """ permission_classes = [IsAuthenticated] @extend_schema( parameters=[ OpenApiParameter('userId', OpenApiTypes.INT, required=False), OpenApiParameter('unit', OpenApiTypes.INT, required=False), ], responses=NodeProgressListResponseSerializer, tags=['Curriculum (content)'], ) def get(self, request): requested_user_id = request.query_params.get('userId') user_id = int(requested_user_id) if requested_user_id else request.user.id if user_id != request.user.id: from family.models import FamilyLink is_verified_parent = FamilyLink.objects.filter( parent_user_id=request.user.id, child_user_id=user_id, status='accepted', ).exists() if not is_verified_parent: raise PermissionDenied('اجازه‌ی مشاهده‌ی پیشرفتِ این کاربر را ندارید.') qs = NodeProgress.objects.filter(user_id=user_id).select_related('node') unit_id = request.query_params.get('unit') if unit_id: qs = qs.filter(node__unit_id=unit_id) data = {'progress': list(qs.order_by('node__unit_id', 'node__order'))} return Response(NodeProgressListResponseSerializer(data).data) class QuestionReportView(APIView): """ POST /api/questions/{id}/report — «این سوال ایراد دارد» چرا لازم بود: تنها بازخوردی که تا امروز جمع می‌شد مالِ چتِ دستیار بود. روی خودِ سوال‌های مسیرِ آموزشی هیچ راهی نبود که کاربر بگوید گزینه‌ی درست غلط است یا صورتِ سوال گنگ — و کسی که محتوا را نگه می‌دارد هیچ سیگنالی نداشت جز حدس. """ permission_classes = [IsAuthenticated] throttle_scope = 'question_report' throttle_classes = [ScopedRateThrottle] @extend_schema( request=QuestionReportRequestSerializer, responses=QuestionReportResponseSerializer, tags=['محتوا (content)'], ) def post(self, request, question_id: int): question = Question.objects.filter(pk=question_id).first() if question is None: raise NotFound('سوال یافت نشد.') serializer = QuestionReportRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) # گزارشِ بازِ تکراریِ همان کاربر روی همان سوال، ردیفِ تازه نمی‌سازد: # دابل‌کلیک نباید صف را شلوغ کند و آمار را به‌هم بزند. report, created = QuestionReport.objects.get_or_create( question=question, user_id=request.user.id, status='open', defaults={ 'reason': serializer.validated_data['reason'], 'note': serializer.validated_data.get('note', ''), }, ) return Response( QuestionReportResponseSerializer({ 'success': True, 'reportId': report.pk, 'alreadyReported': not created, }).data, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, )