import logging from django.conf import settings from django.core.cache import cache from django.db import IntegrityError, transaction from django.db.models import Count, F, Prefetch from django.http import Http404 import random from django.shortcuts import get_object_or_404 from django.utils import timezone 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, 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 .services import ( grade_progress_percent, mark_node_completed_by_quiz, next_node_after, playable_lesson_ids, quiz_completed_node_ids, verified_correct_count, ) from .models import ( Choice, Grade, Lesson, Node, NodeProgress, Question, QuestionReport, Subject, UserAnswer, UserLessonCompletion, UserNodeCompletion, ) from .serializers import ( AnswerRequestSerializer, AnswerResponseSerializer, GradesListResponseSerializer, HomeResponseSerializer, LessonCompleteRequestSerializer, LessonCompleteResponseSerializer, LessonIntroResponseSerializer, LessonQuestionsResponseSerializer, NodeLessonsResponseSerializer, NodeQuizCompleteRequestSerializer, NodeQuizCompleteResponseSerializer, NodeQuizResponseSerializer, NodeProgressListResponseSerializer, QuestionReportRequestSerializer, QuestionReportResponseSerializer, ) logger = logging.getLogger(__name__) # --- کشِ داده‌ی شبهِ‌ثابتِ برنامه‌ی درسی ------------------------------------- # گریدها و درختِ 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(): # lessonCount در یک کوئری می‌آید؛ بدونش فرانت نمی‌فهمد کدام پایه # هنوز خالی است و کاربر صفحه‌ی خالی می‌بیند. grades = Grade.objects.annotate( _lessons=Count('units__nodes__lessons', distinct=True), ).order_by(F('number').asc(nulls_last=True), 'id') return {'grades': [ {'id': g.id, 'number': g.number, 'title': g.title, 'icon': g.icon, 'color': g.color, 'lessonCount': g._lessons} for g in grades ]} return cache.get_or_set(key, build, _CACHE_TTL) def _subjects_payload() -> dict: """{key: {key,title,icon,color,order}} — کلِ جدولِ کتاب‌ها (چند ردیف)، کش‌شده با نسخه‌ی برنامه‌ی درسی.""" key = f'content:subjects:v{curriculum_version()}' def build(): return { s.key: {'key': s.key, 'title': s.title, 'icon': s.icon, 'color': s.color, 'order': s.order} for s in Subject.objects.all() } return cache.get_or_set(key, build, _CACHE_TTL) def grade_subjects(tree: list) -> list[dict]: """کتاب‌هایی که این پایه فصل دارد، به ترتیبِ `Subject.order`.""" table = _subjects_payload() keys = [] for unit in tree: if unit['subject'] not in keys: keys.append(unit['subject']) return sorted((table[k] for k in keys), key=lambda s: s['order']) # پالت و آیکونِ پیش‌فرضِ کارتِ مبحث — هر دو از خودِ اپ گرفته شده‌اند # (رنگ‌های موجود در طرح، و آیکون‌هایی که از lucide وارد می‌شوند)، تا تا وقتی # کسی از پنلِ ادمین مقدارِ دلخواه نگذاشته، کارت‌ها بی‌رنگ نمانند. # # چرخشی و بر اساسِ جایگاهِ مبحث است، نه تصادفی: رنگ نباید بینِ دو درخواست # عوض شود، و دو مبحثِ کنارِ هم نباید یک‌رنگ دربیایند. _NODE_COLORS = ['#ED6956', '#91ADC6', '#97C0C0', '#9B7BFF', '#FF7A3D'] _NODE_ICONS = ['Grid3x3', 'TrendingUp', 'LineChart', 'Sigma', 'Compass', 'Repeat'] def _grade_tree(grade_id: int): """اسکلتِ گریدِ [{id,title,nodes:[{id,title,lesson_ids}]}] — بدونِ status/دیتای کاربر. None یعنی گریدِ ناموجود؛ [] یعنی گریدِ موجودِ بدونِ واحد.""" # tree3: هر فصل حالا `subject` (کلیدِ کتاب) دارد؛ tree2 (و پیش از آن، playable_ids) باطل شد. # پایه‌ی ناموجود کش نمی‌شود: gradeId از query string می‌آید و هر عددِ تازه یک کلیدِ تازه # می‌ساخت. LocMemCache (تا CACHE_URL نیست) بعد از ۳۰۰ کلید یک‌سومِ کهنه‌ها را دور می‌ریزد و # شمارنده‌های throttle (سقفِ ورودِ ادمین، OTP بر اساسِ IP) هم در همین کش‌اند؛ با چند صد # gradeIdِ ساختگی سقفِ ورودِ ادمین صفر می‌شد (بازتولید شد، tests_security_review). if grade_id not in {g['id'] for g in _grades_payload()['grades']}: return None key = f'content:tree3:grade:{grade_id}:v{curriculum_version()}' cached = cache.get(key) if cached is not None: return cached units = ( Grade.objects.get(pk=grade_id) .units.select_related('subject').prefetch_related(Prefetch( 'nodes__lessons', queryset=Lesson.objects.annotate(question_total=Count('questions')), )).order_by('subject__order', 'subject_id', 'order', 'id') ) tree = [] position = 0 # شمارنده‌ی سراسریِ مبحث در این پایه، برای چرخشِ رنگ for unit in units: nodes = [] for node in unit.nodes.all(): nodes.append({ 'id': str(node.id), 'title': node.title, 'icon': node.icon or _NODE_ICONS[position % len(_NODE_ICONS)], 'color': node.color or _NODE_COLORS[position % len(_NODE_COLORS)], 'lesson_ids': [lsn.id for lsn in node.lessons.all()], # فقط درس‌هایی که سوال دارند؛ درسِ بی‌سوال را نمی‌شود «تمام» کرد # (اپ برایش دکمه‌ی شروع ندارد)، پس نباید جزیره را قفل نگه دارد. 'playable_ids': playable_lesson_ids( (lsn.id, lsn.question_total) for lsn in node.lessons.all() ), }) position += 1 tree.append({ 'id': str(unit.id), 'subject': unit.subject.key, 'title': unit.title, 'blurb': unit.blurb, 'nodes': nodes, }) cache.set(key, tree, _CACHE_TTL) return tree class GradesListView(APIView): """GET /api/grades — لیست پایه‌ها""" throttle_scope = 'read' throttle_classes = [ScopedRateThrottle] @extend_schema(responses=GradesListResponseSerializer, tags=['Curriculum (content)']) def get(self, request): return Response(GradesListResponseSerializer(_grades_payload()).data) def _record_streak(user_id): """ فعالیتِ امروز را در استریک ثبت می‌کند. خطایش هرگز نباید ثبتِ پاسخ یا تمام شدنِ درس را خراب کند — استریک تزئینی است، پیشرفت نیست. """ # استریک روزی یک بار عوض می‌شود ولی این تابع سرِ هر جواب صدا زده می‌شود؛ ثبتِ امروز در کشِ Redis # تراکنشِ سه‌کوئریِ بقیه‌ی جواب‌های امروز را حذف می‌کند. کلید فقط بعد از ثبتِ موفق گذاشته می‌شود؛ # بی‌Redis (fail-open) هر بار مثلِ قبل به دیتابیس می‌رود. پاک‌کردنِ دستیِ استریکِ امروزِ کسی از ادمین # تا فردا دوباره ساخته نمی‌شود. today = timezone.localdate() key = f'streak:recorded:{user_id}:{today.isoformat()}' if cache.get(key): return try: from progress.services import record_activity record_activity(user_id, today) cache.set(key, 1, 26 * 3600) except Exception: # noqa: BLE001 logger.exception('streak: ثبتِ فعالیتِ کاربر %s شکست خورد', user_id) _FA_DIGITS = str.maketrans('0123456789', '۰۱۲۳۴۵۶۷۸۹') def no_hearts_response(hearts) -> Response: """۴۰۳ِ «قلب تمام شد»؛ فرانت با code=no_hearts صفحه‌ی انتظار را با شمارشِ معکوس نشان می‌دهد.""" return Response( { 'detail': 'قلب‌هایت تمام شده؛ کمی صبر کن تا دوباره پر شوند.', 'code': 'no_hearts', 'nextRefillAt': hearts.next_refill_at, 'fullRefillAt': hearts.full_refill_at, }, status=status.HTTP_403_FORBIDDEN, ) def blocked_without_hearts(user_id, answered: int, needed: int) -> Response | None: """ پایانِ درس یا جزیره برای کسی که قلب ندارد و سوال‌هایش را جواب نداده: ۴۰۳. جواب دادن بی‌قلب ممکن نیست (QuestionAnswerView)، ولی تا ۲۰۲۶-۱۰-۰۲ پایانِ آزمون (`quiz/complete`) و پایانِ درس هیچ شرطی نداشتند، پس با قلبِ صفر هم می‌شد جزیره پشتِ جزیره جلو رفت. کسی که آخرین قلبش را روی آخرین سوال از دست داده همه را جواب داده و رد نمی‌شود. """ if not settings.HEARTS_ENFORCED or answered >= needed or has_unlimited_hearts(user_id): return None hearts = UserHearts.objects.filter(user_id=user_id).first() if hearts is None: return None if hearts.apply_regen(): hearts.save(update_fields=UserHearts.REGEN_FIELDS) return no_hearts_response(hearts) if hearts.current_hearts <= 0 else None def _answered(user_id, **lookup) -> int: """چند سوالِ متفاوت از این درس/جزیره را این کاربر تا حالا جواب داده.""" return UserAnswer.objects.filter(user_id=user_id, **lookup).values('question_id').distinct().count() def node_locked_response() -> Response: return Response( {'detail': 'این جزیره هنوز قفل است؛ اول جزیره‌ی قبلی را تمام کن.', 'code': 'node_locked'}, status=status.HTTP_403_FORBIDDEN, ) def answers_missing_response(answered: int, required: int) -> Response: return Response( { 'detail': (f'برای تمام کردن باید به همه‌ی سوال‌ها جواب بدهی ({answered} از {required}).' .translate(_FA_DIGITS)), 'code': 'answers_missing', 'answered': answered, 'required': required, }, status=status.HTTP_409_CONFLICT, ) def node_is_unlocked(user_id, node) -> bool: """ آیا این جزیره برای این کاربر روی نقشه قفل نیست؟ همان قاعده‌ی `HomeView` است (`_node_status` روی همان `_grade_tree`): اولین جزیره‌ی هر فصل باز است و بقیه وقتی باز می‌شوند که همه‌ی جزیره‌های قبلیِ همان فصل تمام شده باشند؛ جزیره‌ی تمام‌شده تمام می‌ماند. تست `NodeIsUnlockedMatchesHomeTests` این برابری را نگه می‌دارد. اگر جزیره در درختِ کش‌شده نبود (مثلاً تازه ساخته شده و کش کهنه است) «قفل» حساب می‌شود و در لاگ WARNING می‌ماند؛ همان‌طور که نقشه هم آن را نشان نمی‌دهد. """ tree = _grade_tree(node.unit.grade_id) or [] unit = next((u for u in tree if u['id'] == str(node.unit_id)), None) if unit is not None: lesson_ids = [lid for n in unit['nodes'] for lid in n['lesson_ids']] node_ids = [int(n['id']) for n in unit['nodes']] completed_ids = set(UserLessonCompletion.objects.filter( user_id=user_id, lesson_id__in=lesson_ids).values_list('lesson_id', flat=True)) finished = quiz_completed_node_ids(user_id, node_ids) | set( NodeProgress.objects.filter(user_id=user_id, node_id__in=node_ids, status='completed') .values_list('node_id', flat=True)) unlock_next = True for n in unit['nodes']: playable = n.get('playable_ids') or n['lesson_ids'] node_status = _node_status(playable, completed_ids, unlock_next, quiz_completed=int(n['id']) in finished) if int(n['id']) == node.id: return node_status != 'locked' if node_status != 'completed': unlock_next = False logger.warning('node_is_unlocked: جزیره %s در درختِ پایه %s پیدا نشد؛ قفل حساب شد', node.id, node.unit.grade_id) return False def unit_is_free(unit_id: int, grade_id: int) -> bool: """ فصلِ رایگان: فقط اولین فصلِ هر کتابِ هر پایه (به ترتیبِ `Unit.order`، همان ترتیبِ نقشه). پیش از افزوده‌شدنِ کتاب‌های هندسه/آمار/گسسته (۲۰۲۶-۱۰-۱۱) هر پایه یک کتاب داشت و «اولین فصلِ پایه» همین بود؛ حالا هر کتاب فصلِ اولش را رایگان دارد تا دانش‌آموزِ دوازدهم هر سه کتاب را بچشد. این تعبیر را ما از «یک فصل رایگان» کردیم و سینیور تأیید نکرده؛ اگر نخواست، شرطِ بالا را به `tree[0]` برگردانید. بقیه‌ی فصل‌ها اشتراکِ زمانی می‌خواهند (تصمیمِ سینیور و مدیرعامل، ۲۰۲۶-۱۰-۱۰: «هر کاربر یک درس را ببرد جلو، برای بقیه اشتراک بخرد»؛ مالکِ پروژه «درس» را «فصل» گرفت). """ tree = _grade_tree(grade_id) or [] unit = next((u for u in tree if u['id'] == str(unit_id)), None) if unit is None: return False first_of_subject = next(u for u in tree if u['subject'] == unit['subject']) return first_of_subject['id'] == str(unit_id) def has_unit_access(user_id, unit_id: int, grade_id: int) -> bool: from subscription.services import has_active_subscription if unit_is_free(unit_id, grade_id): return True return user_id is not None and has_active_subscription(user_id) def subscription_required_response() -> Response: # ۴۰۲ و نه ۴۰۳: فرانت از روی همین کد به صفحه‌ی اشتراک می‌رود، نه پیامِ «قفل است». return Response( {'detail': 'این فصل با اشتراک باز می‌شود. فصلِ اولِ هر پایه رایگان است.', 'code': 'subscription_required'}, status=status.HTTP_402_PAYMENT_REQUIRED, ) def _user_id_or_none(request): return request.user.id if getattr(request.user, 'is_authenticated', False) else None def _completion_body(title, stars, xp): """ متنِ اعلانِ «تمام کردی». عددها فارسی، چون اپ متنِ اعلان را همان‌طور که هست نشان می‌دهد؛ و بی‌امتیاز جمله‌ی «با ۰ ستاره … ۰ امتیاز» ساخته نمی‌شود. """ if not xp: return f'«{title}» را تمام کردی.' return f'«{title}» را با {stars} ستاره تمام کردی و {xp} امتیاز گرفتی.'.translate(_FA_DIGITS) def _node_status(lesson_ids, completed_ids, is_first_unlocked_candidate, quiz_completed=False): # آزمونِ مبحث تمام شده → مبحث تمام است، مستقل از تک‌تکِ درس‌ها. if quiz_completed: return 'completed' 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?gradeId — نقشه‌ی مهارت‌ها + آمار (ادغام‌شده)""" # userId همیشه از request.user.id (توکن معتبر) گرفته می‌شود، نه ورودی کلاینت (IDOR). permission_classes = [IsAuthenticated] throttle_scope = 'read' throttle_classes = [ScopedRateThrottle] @extend_schema( parameters=[ OpenApiParameter('gradeId', OpenApiTypes.INT, required=False, description='پایه‌ای که نقشه‌اش خواسته می‌شود؛ ' 'بدونِ آن، پایه‌ی ذخیره‌شده‌ی خودِ کاربر.'), OpenApiParameter('subject', OpenApiTypes.STR, required=False, description='کلیدِ کتاب (`subjects[].key` در همین پاسخ). بدونِ آن اولین ' 'کتابِ پایه؛ کتابِ ناموجود در این پایه ۴۰۰.'), ], responses=HomeResponseSerializer, tags=['Curriculum (content)'], ) def get(self, request): user_id = request.user.id # ⚠️ پیش‌تر فقط پارامترِ صریحِ ?grade= خوانده می‌شد و پایه‌ی ذخیره‌شده‌ی # خودِ کاربر نادیده می‌ماند؛ نتیجه این بود که اپ در «آشنایی» پایه را ثبت # می‌کرد و بعد صفحه‌ی خانه خالی برمی‌گشت. حالا پارامتر اولویت دارد (برای # مرورِ پایه‌ی دیگر) و در نبودش پایه‌ی خودِ کاربر ملاک است. # فقط `gradeId` (هم‌نام با بقیه‌ی API). نامِ قدیمیِ `grade` در ۲۰۲۶-۱۰-۰۳ # حذف شد: تنها کلاینت (MathApp_frontend) آن را نمی‌فرستد. فرستادنش ۴۰۰ است، # نه نادیده گرفتنِ بی‌صدا (که کاربر را به نقشه‌ی پایه‌ی خودش می‌برد). if 'grade' in request.query_params: raise ValidationError({'grade': 'این پارامتر حذف شده؛ gradeId بفرستید.'}) grade_param = request.query_params.get('gradeId') if grade_param: try: grade_id = int(grade_param) except (TypeError, ValueError): raise Http404 else: grade_id = request.user.grade_id tree = [] subjects = [] active_subject = None subject_param = request.query_params.get('subject') if grade_id: tree = _grade_tree(grade_id) if tree is None: # پایه‌ای که کاربر ذخیره کرده ممکن است بعداً حذف شده باشد؛ آن # حالت خطا نیست، فقط نقشه‌ی خالی است. ولی ?grade=ی که کاربر # صریحاً خواسته و وجود ندارد، ۴۰۴ است. if grade_param: raise Http404 tree = [] else: # یک پایه چند کتاب دارد (دوازدهم: حسابان، هندسه، گسسته). بی‌`subject` اولین کتابِ # پایه (به ترتیبِ `Subject.order`) باز می‌شود؛ فرانت آخرین انتخابِ کاربر را خودش می‌فرستد. # کلیدِ نامعتبر یا کتابی که این پایه ندارد ۴۰۰ است، نه نقشه‌ی پایه‌ی خودِ کاربر. subjects = grade_subjects(tree) if subject_param: if subject_param not in {s['key'] for s in subjects}: raise ValidationError({'subject': 'این پایه چنین کتابی ندارد.'}) active_subject = subject_param else: active_subject = subjects[0]['key'] if subjects else None tree = [u for u in tree if u['subject'] == active_subject] # همه‌ی lesson_idهای کامل‌شده‌ی این کاربر در همین گرید — یک کوئری، # مستقل از تعدادِ node (رفعِ N+1). all_lesson_ids = [lid for unit in tree for node in unit['nodes'] for lid in node['lesson_ids']] # مخرجِ درصدِ پیشرفت: فقط درس‌های سوال‌دار (همان که جزیره را تمام می‌کند). all_playable_ids = [ lid for unit in tree for node in unit['nodes'] for lid in (node.get('playable_ids') or node['lesson_ids']) ] # همه‌ی درس‌های تمام‌شده‌ی کاربر و بعد اشتراک در پایتون، نه `lesson_id IN (…۶۰۰ شناسه…)`: ساختنِ # آن کوئری در جنگو/psycopg بیشترِ زمانِ CPUِ این صفحه بود (پروفایلِ ۲۰۲۶-۱۰-۱۱)، و ردیف‌های یک # کاربر با ایندکسِ user_id خوانده می‌شوند. completed_ids = set() if all_lesson_ids: completed_ids = set( UserLessonCompletion.objects.filter(user_id=user_id).values_list('lesson_id', flat=True) ).intersection(all_lesson_ids) tree_node_ids = [int(node['id']) for unit in tree for node in unit['nodes']] quiz_done = quiz_completed_node_ids(user_id, tree_node_ids) # جزیره‌ای که یک بار تمام شده، تمام می‌ماند. بدونِ این، اضافه شدنِ اولین # سوال به یک درسِ بی‌سوال (از ادمین) جزیره‌ی تمام‌شده را باز می‌کرد و # جزیره‌ی بعدیِ دانش‌آموز را دوباره قفل می‌کرد — پیشرفت عقب می‌رفت. finished = quiz_done | set( NodeProgress.objects.filter( user_id=user_id, node_id__in=tree_node_ids, status='completed', ).values_list('node_id', flat=True) ) from subscription.services import has_active_subscription subscribed = user_id is not None and has_active_subscription(user_id) units_payload = [] for index, unit in enumerate(tree): node_payload = [] unlock_next = True # اولین نودِ قفل‌نشده در هر واحد، باز می‌مونه for node in unit['nodes']: playable = node.get('playable_ids') or node['lesson_ids'] node_status = _node_status( playable, completed_ids, unlock_next, quiz_completed=int(node['id']) in finished, ) next_lesson_id = next( (lid for lid in playable if lid not in completed_ids), playable[0] if playable else None, ) if node_status != 'completed': unlock_next = False node_payload.append({ 'id': node['id'], 'title': node['title'], 'icon': node.get('icon', ''), 'color': node.get('color', ''), 'status': node_status, 'lessonIds': ( [next_lesson_id] + [lid for lid in node['lesson_ids'] if lid != next_lesson_id] if next_lesson_id is not None else node['lesson_ids'] ), 'nextLessonId': next_lesson_id, }) units_payload.append({ 'id': unit['id'], 'title': unit['title'], 'blurb': unit.get('blurb', ''), # فقط فصلِ اولِ هر پایه رایگان است (`unit_is_free`)؛ true یعنی همین کاربر برای بازی‌کردنش # اشتراک لازم دارد. وضعیتِ جزیره‌ها عوض نمی‌شود تا پیشرفتِ کسی که اشتراکش تمام شد گم نشود. 'requiresSubscription': index > 0 and not subscribed, 'nodes': node_payload, }) # درصدِ پیشرفت برای همین پایه‌ای که نقشه‌اش نشان داده می‌شود، زنده از # همین داده‌ها: درس‌های تمام‌شده + همه‌ی درس‌های مبحث‌هایی که آزمونشان # تمام شده. UserStats.progress_percent فقط در لحظه‌ی تکمیل به‌روز # می‌شود و به پایه‌ی دیگر (?gradeId=) ربطی ندارد. playable_set = set(all_playable_ids) done_lessons = set(completed_ids) & playable_set for unit in tree: for node in unit['nodes']: if int(node['id']) in quiz_done: done_lessons.update(playable_set.intersection(node['lesson_ids'])) progress_percent = ( round(min(len(done_lessons) / len(playable_set), 1.0) * 100, 2) if playable_set else 0.0 ) stats = UserStats.objects.filter(user_id=user_id).first() stats_payload = { 'progressPercent': progress_percent, 'xp': stats.xp if stats else 0, 'coins': stats.coins if stats else 0, } data = { 'subject': next((s for s in subjects if s['key'] == active_subject), None) if subjects else None, 'subjects': subjects, 'units': units_payload, 'stats': stats_payload, } # خودِ dict، نه HomeResponseSerializer(data).data: سریال‌سازِ تو در توی DRF برای ~۳۰ جزیره حدودِ ۱٫۶ # میلی‌ثانیه از ~۸٫۶ِ این پرترافیک‌ترین صفحه بود (پروفایلِ ۲۰۲۶-۱۰-۱۱) و چیزی را تبدیل نمی‌کرد. # سریال‌ساز همچنان قراردادِ Swagger است؛ HomeMatchesItsSerializerTests برابریِ این دو را نگه می‌دارد. return Response(data) class LessonIntroView(APIView): """GET /api/lessons/{id}/intro""" # بی‌ورود هم باز است؛ بدونِ سقف، کلِ متنِ درس‌ها با یک حلقه قابلِ کپی بود. throttle_scope = 'read' throttle_classes = [ScopedRateThrottle] @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, 'kind': lesson.kind, 'introText': lesson.intro_text, 'example': lesson.example, 'steps': lesson.steps or [], 'figures': lesson.figures or [], 'videoUrl': lesson.video_url, 'durationMinutes': lesson.duration_minutes, 'mascotAnimation': lesson.mascot_animation, 'questionCount': lesson.questions.count(), } return Response(LessonIntroResponseSerializer(data).data) class LessonQuestionsView(APIView): """GET /api/lessons/{id}/questions""" # بی‌ورود هم باز است؛ بدونِ سقف، کلِ بانکِ سوال با یک حلقه قابلِ کپی بود. throttle_scope = 'read' throttle_classes = [ScopedRateThrottle] @extend_schema(responses=LessonQuestionsResponseSerializer, tags=['Curriculum (content)']) def get(self, request, lesson_id): lesson = get_object_or_404(Lesson.objects.select_related('node__unit'), pk=lesson_id) if not has_unit_access(_user_id_or_none(request), lesson.node.unit_id, lesson.node.unit.grade_id): return subscription_required_response() 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 رو تغییر می‌دن؛ # برای همین throttle لازمه. throttle_scope = 'question_answer' throttle_classes = [ScopedRateThrottle] @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.objects.select_related('lesson__node__unit'), pk=question_id) unit = question.lesson.node.unit if not has_unit_access(request.user.id, unit.id, unit.grade_id): return subscription_required_response() serializer = AnswerRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) selected_option_id = serializer.validated_data['selectedOptionId'] # یک کوئری برای هر چهار گزینه، به‌جای یکی برای انتخاب و یکی برای گزینه‌ی درست. choices = list(question.choices.all()) choice = next((c for c in choices if c.id == selected_option_id), None) if choice is None: raise Http404 correct_choice = next((c for c in choices if c.is_correct), None) 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) جلوی ثبت دوباره با همان کلید را می‌گیرد. # قلبِ صفر = نمی‌شود جواب داد (مثلِ دولینگو)، مگر قلبِ نامحدود (اشتراک یا کارتِ قلبِ بسته). # بدونِ این، «قلبِ نامحدود» فقط در ظاهرِ اپ وجود داشت و با صدا زدنِ مستقیمِ API دور زده # می‌شد. چکِ قلب داخلِ همان تراکنشی است که قلب را کم می‌کند: تا ۲۰۲۶-۱۰-۰۲ بیرونش بود و # دو جوابِ هم‌زمان با یک قلبِ باقی‌مانده هر دو پذیرفته می‌شدند (فقط خوانده شد، بازتولید نه). unlimited = has_unlimited_hearts(user_id) try: with transaction.atomic(): hearts = None if settings.HEARTS_ENFORCED and not unlimited: hearts = UserHearts.objects.select_for_update().filter(user_id=user_id).first() if hearts is not None and hearts.apply_regen(): hearts.save(update_fields=UserHearts.REGEN_FIELDS) if hearts is not None and hearts.current_hearts <= 0: return no_hearts_response(hearts) 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 unlimited: hearts.lose_heart() hearts.save(update_fields=UserHearts.REGEN_FIELDS) hearts_remaining = hearts.current_hearts else: hearts = hearts or 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', } _record_streak(user_id) 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 با همون کلید رو می‌گیره؛ برای همینه که # throttle لازمه. نرخ در # settings (`lesson_complete`، الان ۱۲۰/hour) کمتر از question_answer است # چون این endpoint رکوردِ ماندگار می‌سازه. throttle_scope = 'lesson_complete' throttle_classes = [ScopedRateThrottle] @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}) بیشتر باشد.' ) }) # امتیاز فقط برای جواب‌هایی که واقعاً ثبت شده‌اند. پیش‌تر عددِ کلاینت # (فقط با سقفِ تعدادِ سوال‌ها) پذیرفته می‌شد، پس صدا زدنِ مستقیمِ همین # endpoint برای هر درس، بی‌جواب دادن، امتیاز و جدولِ رتبه‌بندی را پر می‌کرد. # ⚠️ جلوی «غلط بزن، کلید را ببین، درست بزن» را نمی‌گیرد # (verified_correct_count را ببینید)، و همین جواب‌ها در پایانِ آزمونِ # جزیره دوباره امتیاز می‌گیرند. هر دو در بازبینیِ ۲۰۲۶-۰۹-۲۵ بازتولید شد. correct_count = min(correct_count, verified_correct_count(request.user.id, lesson_id=lesson.id)) # 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) # درسِ بی‌سوال چیزی برای جواب‌دادن ندارد و (اگر جزیره‌اش باز است) مثلِ قبل رد می‌شود، # بی‌امتیاز؛ فقط قفل بودنِ جزیره‌اش بررسی می‌شود. # جزیره‌ی قفل را با تمام‌کردنِ درس‌هایش هم نمی‌شود باز کرد (وضعیتِ «باز» از روی # درس‌های تمام‌شده هم حساب می‌شود، پس بدونِ این قفل از همین راه دور زده می‌شد). if not has_unit_access(user_id, lesson.node.unit_id, lesson.node.unit.grade_id): return subscription_required_response() if not node_is_unlocked(user_id, lesson.node): return node_locked_response() answered = _answered(user_id, question__lesson=lesson) blocked = blocked_without_hearts(user_id, answered, actual_question_count) if blocked is not None: return blocked if answered < actual_question_count: return answers_missing_response(answered, actual_question_count) # 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 stats.progress_percent = grade_progress_percent( user_id, getattr(request.user, 'grade_id', None) or lesson.node.unit.grade_id, ) stats.save(update_fields=['xp', 'coins', 'progress_percent']) # اعلانِ درسِ تکمیل‌شده. تا پیش از این، نوعِ 'lesson_completed' # در Notification.TYPE_CHOICES تعریف شده بود ولی هیچ‌جای کد # آن را نمی‌ساخت — یعنی کلِ مسیرِ کوییز هیچ اعلانی تولید # نمی‌کرد. فقط بارِ اول (همان شرطی که xp/coins می‌دهد)؛ # تکرارِ درس اعلانِ تازه نمی‌سازد. # # عمداً داخلِ همین transaction است: اگر نوشتنِ اعلان شکست # بخورد، تکمیلِ درس هم برنمی‌گردد — هم‌الگو با # campaigns.services که اعلان و وضعیت را یک واحد می‌داند. if is_first_completion: from notifications.services import notify notify( user_id=user_id, type='lesson_completed', title='درس را کامل کردی', body=_completion_body(lesson.title, stars, xp_earned), related_id=lesson.id, ) 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, } _record_streak(user_id) return Response(LessonCompleteResponseSerializer(data).data) class NodeProgressListView(APIView): """ GET /api/progress/nodes?unit= — لیست وضعیتِ خودِ کاربر روی هر Node. توکن اجباری است و فقط دیتای صاحبِ توکن برمی‌گردد. تا ۲۰۲۶-۱۰-۰۱ پارامترِ userId داشت تا والدِ وصل‌شده پیشرفتِ فرزند را ببیند؛ بخشِ والدین از ریشه حذف شد و والد گزارش را در حسابِ خودِ دانش‌آموز می‌بیند (GET /api/user/weekly-report). """ permission_classes = [IsAuthenticated] throttle_scope = 'read' throttle_classes = [ScopedRateThrottle] @extend_schema( parameters=[ OpenApiParameter('unit', OpenApiTypes.INT, required=False), ], responses=NodeProgressListResponseSerializer, tags=['Curriculum (content)'], ) def get(self, request): # lessons_total یک‌جا در همین کوئری؛ پیش‌تر سریالایزر برای هر ردیف یک COUNT جدا می‌زد. qs = ( NodeProgress.objects.filter(user_id=request.user.id).select_related('node') .annotate(lessons_total=Count('node__lessons')) ) unit_id = request.query_params.get('unit') if unit_id: # سقفِ طول: عددِ بزرگ‌تر از INTEGERِ SQLite روی فیلترِ node__unit_id خطای ۵۰۰ می‌داد. if not (unit_id.isascii() and unit_id.isdigit() and len(unit_id) <= 9): raise ValidationError({'unit': 'شناسه‌ی فصل باید عدد باشد.'}) qs = qs.filter(node__unit_id=int(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=['Curriculum (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, ) class NodeQuizView(APIView): """ GET /api/nodes/{id}/quiz — یک آزمونِ آماده برای کلِ مبحث. چرا لازم شد: واحدِ بازی در اپ «مبحث» است (همان جزیره‌ی روی نقشه)، ولی سوال‌ها به «درس» بسته‌اند و درس در این پروژه ریزترین دانه است — یک نوعِ مسئله‌ی استخراج‌شده از کتاب، نه یک جلسه‌ی درسی. نتیجه‌اش این بود که هر درس یکی-دو سوال داشت و اپ برای ساختنِ یک آزمونِ چهارسوالی باید ده‌ها درخواستِ `lessons/{id}/questions` می‌زد و خودش سرِ هم می‌کرد. این اندپوینت همان کار را یک‌جا می‌کند: از همه‌ی درس‌های مبحث نمونه برمی‌دارد و یک آزمون می‌دهد. ترتیب تصادفی نیست: `?seed=` یا شناسه‌ی کاربر مبنای درهم‌ریزی است تا دانش‌آموزی که صفحه را رفرش می‌کند همان آزمون را ببیند، نه مجموعه‌ی تازه‌ای که پیشرفتش را بی‌معنا کند. """ throttle_scope = 'read' throttle_classes = [ScopedRateThrottle] DEFAULT_SIZE = 4 MAX_SIZE = 20 @extend_schema(responses=NodeQuizResponseSerializer, tags=['Curriculum (content)']) def get(self, request, node_id: int): node = get_object_or_404( Node.objects.select_related('unit'), pk=node_id, ) if not has_unit_access(_user_id_or_none(request), node.unit_id, node.unit.grade_id): return subscription_required_response() try: size = int(request.query_params.get('size', self.DEFAULT_SIZE)) except (TypeError, ValueError): size = self.DEFAULT_SIZE size = max(1, min(size, self.MAX_SIZE)) questions = list( Question.objects .filter(lesson__node_id=node_id) .select_related('lesson') .prefetch_related('choices') .order_by('lesson__id', 'id') ) # درهم‌ریزیِ پایدار: همان کاربر روی همان مبحث همیشه همان ترتیب را # می‌بیند، ولی دو کاربرِ مختلف دو ترتیبِ مختلف. seed = request.query_params.get('seed') or str(getattr(request.user, 'id', '') or '') rng = random.Random(f'{node_id}:{seed}') rng.shuffle(questions) # حداکثر یک سوال از هر درس تا آزمون روی یک مهارت گیر نکند؛ اگر با این # شرط به اندازه‌ی لازم نرسیدیم، بقیه از همان استخر پر می‌شوند. picked, seen_lessons = [], set() for question in questions: if question.lesson_id in seen_lessons: continue seen_lessons.add(question.lesson_id) picked.append(question) if len(picked) >= size: break if len(picked) < size: for question in questions: if question in picked: continue picked.append(question) if len(picked) >= size: break return Response(NodeQuizResponseSerializer({ 'nodeId': str(node.pk), 'nodeTitle': node.title, 'unitTitle': node.unit.title, 'questionCount': len(picked), 'requested': size, 'questions': [ { 'id': str(q.pk), 'lessonId': str(q.lesson_id), 'lessonTitle': q.lesson.title, 'type': q.type, 'prompt': q.prompt, 'options': [ {'id': str(c.pk), 'value': c.value} for c in q.choices.all() ], } for q in picked ], }).data) class NodeQuizCompleteView(APIView): """ POST /api/nodes/{id}/quiz/complete — پایانِ آزمونِ یک جزیره. قاعده ساده است: آزمون که تمام شد، جزیره تمام است و جزیره‌ی بعدی باز می‌شود، هر نمره‌ای که گرفته باشد. نمره فقط ستاره و امتیاز را تعیین می‌کند. تا پیش از این اندپوینت، جزیره فقط با تمام کردنِ تک‌تکِ درس‌هایش (۸ تا ۱۳ درس) تمام می‌شد و اپ که با آزمونِ جزیره بازی می‌کند هیچ‌وقت به آن نمی‌رسید؛ دانش‌آموز بعد از جزیره‌ی اولِ هر فصل گیر می‌کرد. فقط بارِ اول امتیاز و سکه می‌دهد (مثلِ lessons/{id}/complete)؛ تکرارِ آزمون ثبت می‌شود ولی جایزه‌ی تازه ندارد. """ permission_classes = [IsAuthenticated] throttle_scope = 'node_complete' throttle_classes = [ScopedRateThrottle] @extend_schema( request=NodeQuizCompleteRequestSerializer, responses=NodeQuizCompleteResponseSerializer, parameters=[ OpenApiParameter( 'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False, description='رتری/دابل‌کلیک با همان کلید ردیف یا جایزه‌ی تکراری نمی‌سازد.', ), ], tags=['Curriculum (content)'], ) def post(self, request, node_id: int): node = get_object_or_404(Node.objects.select_related('unit'), pk=node_id) serializer = NodeQuizCompleteRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) correct_count = serializer.validated_data['correctCount'] total_questions = serializer.validated_data['totalQuestions'] # سقف‌ها از روی خودِ مبحث، نه از روی حرفِ کلاینت: آزمون بیشتر از # استخرِ سوال‌های مبحث (و سقفِ NodeQuizView) سوال ندارد، و پاسخِ درست # بیشتر از سوال‌ها نمی‌شود. # ⚠️ سقف «کلِ استخر» است (تا ۲۰)، نه آزمونِ چهارسوالی‌ای که واقعاً داده # شد؛ جواب‌های درستِ مسیرِ درس‌ها هم اینجا دوباره شمرده می‌شوند. قفل بودنِ # جزیره هم بررسی نمی‌شود: هر جزیره‌ای با correctCount=0 «تمام» می‌شود. pool = min(Question.objects.filter(lesson__node_id=node_id).count(), NodeQuizView.MAX_SIZE) if total_questions > max(pool, 1): raise ValidationError({ 'totalQuestions': f'این مبحث حداکثر {pool} سوال دارد.', }) if correct_count > total_questions: raise ValidationError({ 'correctCount': 'correctCount نمی‌تواند از totalQuestions بیشتر باشد.', }) # مثلِ پایانِ درس: فقط جواب‌های درستِ ثبت‌شده در همین مبحث شمرده می‌شوند. correct_count = min(correct_count, verified_correct_count(request.user.id, node_id=node_id)) # تعدادِ سوالِ آزمون را کلاینت نمی‌تواند کمتر از آزمونِ استانداردِ سرور بگوید (با # totalQuestions=1 و یک جوابِ درست ۳ ستاره می‌گرفت). بیشتر هم بگوید، باید همان‌قدر جواب بدهد. # جزیره‌ی بی‌سوال چیزی برای جواب‌دادن ندارد: اگر باز است رد می‌شود (بی‌ستاره و بی‌امتیاز) # تا مسیرِ بقیه را نبندد؛ قفل بودنش همچنان بررسی می‌شود. total_questions = max(total_questions, min(NodeQuizView.DEFAULT_SIZE, pool)) if pool else 0 ratio = correct_count / total_questions if total_questions else 0 stars = 3 if ratio >= 0.9 else 2 if ratio >= 0.6 else 1 if ratio > 0 else 0 user_id = request.user.id idempotency_key = request.headers.get('Idempotency-Key') or None next_node = next_node_after(node) def respond(completion, stats): return Response(NodeQuizCompleteResponseSerializer({ 'nodeId': str(node.pk), 'stars': completion.stars, 'xpEarned': completion.xp_earned, 'coinsEarned': completion.coins_earned, 'nextNodeId': str(next_node.pk) if next_node else None, 'stats': { 'progressPercent': stats.progress_percent if stats else 0, 'xp': stats.xp if stats else 0, 'coins': stats.coins if stats else 0, }, }).data) if idempotency_key: existing = UserNodeCompletion.objects.filter( user_id=user_id, node=node, idempotency_key=idempotency_key, ).first() if existing is not None: return respond(existing, UserStats.objects.filter(user_id=user_id).first()) # قفل: جزیره‌ی قفل را نمی‌شود تمام کرد (تا ۲۰۲۶-۱۰-۰۷ می‌شد، با correctCount=0). if not has_unit_access(user_id, node.unit_id, node.unit.grade_id): return subscription_required_response() if not node_is_unlocked(user_id, node): return node_locked_response() answered = _answered(user_id, question__lesson__node_id=node_id) blocked = blocked_without_hearts(user_id, answered, total_questions) if blocked is not None: return blocked # جوابِ ثبت‌شده: بی‌جواب‌دادن، حتی با قلبِ پر، جزیره را تمام نمی‌کند. نمره مهم نیست # (تصمیمِ ۲۰۲۶-۰۹-۲۴: هر نمره‌ای)، فقط باید همه‌ی سوال‌ها جواب داده شده باشند. # ⚠️ «جواب داده» یعنی سوالِ متمایزی از همین جزیره، نه لزوماً همان چهار سوالی که این بار # نشان داده شد؛ کاربری که پیش‌تر جواب داده دوباره‌بازی را بی‌جوابِ تازه می‌بندد. if answered < total_questions: return answers_missing_response(answered, total_questions) 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 = not UserNodeCompletion.objects.filter( user_id=user_id, node=node, ).exists() completion = UserNodeCompletion.objects.create( user_id=user_id, node=node, correct_count=correct_count, total_questions=total_questions, stars=stars, xp_earned=correct_count * 10 if is_first else 0, coins_earned=stars * 5 if is_first else 0, idempotency_key=idempotency_key, ) stats.xp += completion.xp_earned stats.coins += completion.coins_earned stats.progress_percent = grade_progress_percent( user_id, getattr(request.user, 'grade_id', None) or node.unit.grade_id, ) stats.save(update_fields=['xp', 'coins', 'progress_percent']) mark_node_completed_by_quiz(user_id, node) if is_first: from notifications.services import notify notify( user_id=user_id, type='node_completed', title='جزیره را تمام کردی', body=_completion_body(node.title, stars, completion.xp_earned), related_id=node.id, ) except IntegrityError: # درخواستِ هم‌زمان با همان کلید؛ نتیجه‌ی رقیب برگردانده می‌شود. if not idempotency_key: raise completion = UserNodeCompletion.objects.filter( user_id=user_id, node=node, idempotency_key=idempotency_key, ).first() if completion is None: raise stats = UserStats.objects.filter(user_id=user_id).first() _record_streak(user_id) return respond(completion, stats) class NodeLessonsView(APIView): """ GET /api/nodes/{id}/lessons — قدم‌های یک مبحث برای نقشه‌ی مهارت. چرا لازم بود: /api/home کلِ درخت را می‌دهد ولی فقط شناسه‌ی درس‌ها را؛ صفحه‌ی نقشه‌ی مهارت برای هر قدم عنوان، نوع (آزمون/ویدیو)، زیرنویس و «انجام شد یا نه» می‌خواهد. بدونِ این، فرانت باید برای هر درس یک درخواستِ intro جدا می‌زد — یعنی ده‌ها درخواست برای یک صفحه. """ throttle_scope = 'read' throttle_classes = [ScopedRateThrottle] @extend_schema(responses=NodeLessonsResponseSerializer, tags=['Curriculum (content)']) def get(self, request, node_id: int): node = get_object_or_404( Node.objects.prefetch_related('lessons__questions'), pk=node_id, ) completed = set() user_id = getattr(request.user, 'id', None) if user_id is not None: completed = set( UserLessonCompletion.objects .filter(user_id=user_id, lesson__node_id=node_id) .values_list('lesson_id', flat=True) ) lessons = [] for lesson in node.lessons.all(): if lesson.kind == 'video': meta = f'{lesson.duration_minutes} دقیقه' if lesson.duration_minutes else '' else: count = lesson.questions.count() meta = f'{count} سوال' if count else '' lessons.append({ 'id': str(lesson.id), 'kind': lesson.kind, 'title': lesson.title, 'meta': meta, 'done': lesson.id in completed, }) return Response(NodeLessonsResponseSerializer({ 'nodeId': str(node.id), 'title': node.title, 'lessons': lessons, }).data)