1290 lines
66 KiB
Python
1290 lines
66 KiB
Python
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 user_has_all_nodes_unlocked(user_id) -> bool:
|
||
"""
|
||
کاربرِ آزمونگر (`settings.UNLOCK_ALL_PHONES`): قفلِ ترتیبِ جزیرهها برایش برداشته میشود.
|
||
وقتی فهرست خالی است (حالتِ عادی) هیچ کوئریای زده نمیشود.
|
||
"""
|
||
from django.conf import settings
|
||
if user_id is None or not settings.UNLOCK_ALL_PHONES:
|
||
return False
|
||
from accounts.models import User
|
||
return User.objects.filter(pk=user_id, phone__in=settings.UNLOCK_ALL_PHONES).exists()
|
||
|
||
|
||
def node_is_unlocked(user_id, node) -> bool:
|
||
"""
|
||
آیا این جزیره برای این کاربر روی نقشه قفل نیست؟ همان قاعدهی `HomeView` است
|
||
(`_node_status` روی همان `_grade_tree`): اولین جزیرهی هر فصل باز است و بقیه
|
||
وقتی باز میشوند که همهی جزیرههای قبلیِ همان فصل تمام شده باشند؛ جزیرهی
|
||
تمامشده تمام میماند. تست `NodeIsUnlockedMatchesHomeTests` این برابری را نگه میدارد.
|
||
|
||
اگر جزیره در درختِ کششده نبود (مثلاً تازه ساخته شده و کش کهنه است) «قفل» حساب میشود
|
||
و در لاگ WARNING میماند؛ همانطور که نقشه هم آن را نشان نمیدهد.
|
||
"""
|
||
if user_has_all_nodes_unlocked(user_id):
|
||
return True
|
||
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)
|
||
unlock_all = user_has_all_nodes_unlocked(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
|
||
if unlock_all and node_status == 'locked':
|
||
node_status = 'unlocked'
|
||
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()],
|
||
'figure': q.figure,
|
||
}
|
||
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,
|
||
'figure': q.figure,
|
||
'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)
|