سه کارِ بههمپیوسته که از یک کشف شروع شدند: راهحلهایی که vision از کتاب
درآورده گاهی غلطاند، و این خطا در بازیابی دیده نمیشود — فقط وقتی کسی به
`solution` اعتماد کند خودش را نشان میدهد.
۱) بازبینیِ راهحلها (rag_verify_solutions)
هر chunk دوباره حل میشود و با کتاب مقایسه. نتیجه سهحالته است، نه بله/خیر:
اجرای اول با بولی ۱۹٪ خطا گزارش کرد؛ خواندنِ دستیِ نمونهها نشان داد ۳۵٪ از
آنها مسئلههایی بودند که به شکلِ کتاب ارجاع میدادند و اصلاً قابلِ قضاوت
نبودند، و ۲۴٪ موردی که «۳/۱۰۴» را کسرِ ۳÷۱۰۴ خوانده بود نه عددِ ۳٫۱۰۴. با دو
حالت، «نمیدانم» ناچار به «غلط است» ترجمه میشد.
با سه حالت و پرامپتی که این دو تله را میشناسد: ۲٬۱۱۹ chunk بررسی شد،
۱٬۱۱۲ درست، ۱۲۳ غلط (۵٫۸٪)، ۸۸۱ غیرقابلِ قضاوت. آن ۸۸۱ رایگان برچسب خوردند
چون پیشفیلتر قبل از هر فراخوانی کنارشان گذاشت.
`solution` هرگز بازنویسی نمیشود — کتاب باید کتاب بماند. برچسب در payloadِ
Qdrant هم مینشیند، ولی با نامی که `retrieval._payload_text` نمیخواند: یک
نویسه هم به متنی که به مدل میرسد اضافه نشد.
نمونهی خطاهای واقعیِ کتاب که پیدا شد:
BMI = ۱۰۰/(۲٫۷۴)² کتاب: ۳۳٫۲ درست: ۱۳٫۳
۱۶ آبی + ۲۴ صورتی کتاب: «کل ۵۰» درست: ۴۰
بزرگترین عددِ ۴رقمیِ بیتکرار کتاب: ۸۵۳۲ درست: ۹۸۷۶
g(x) = x³ − ۲ کتاب: «نمایی» درست: چندجملهای
۲) سوالها فقط از راهحلهای تأییدشده
۱٬۱۹۹ سوال در content/seed/questions.json، همه از chunkهایی که برچسبِ
«درست» گرفتهاند. هر شش پایه برنامهی درسی دارند (۱٬۹۲۸ درس) — هشتم و دهم
تازه ساخته شدند.
۳) GET /api/nodes/{id}/quiz
واحدِ بازی در اپ «مبحث» است ولی سوالها به «درس» بستهاند، و درس اینجا
ریزترین دانه است: یک نوعِ مسئله، نه یک جلسه. نتیجهاش این بود که اپ برای یک
آزمونِ چهارسوالی باید دهها درخواست میزد. این اندپوینت از همهی درسهای مبحث
نمونه برمیدارد، حداکثر یکی از هر درس تا آزمون روی یک مهارت گیر نکند.
درهمریزی با بذرِ nodeId:userId پایدار است: رفرشِ صفحه همان آزمون را میدهد،
نه مجموعهی تازهای که پیشرفت را بیمعنا کند. `questionCount` جدا از
`requested` میآید تا مبحثِ کمسوال وانمود نکند کامل است.
──────────
اصلاحاتِ جانبی که سرِ راه لازم شدند:
• QDRANT_TIMEOUT_SECONDS: کلاینت بدونِ timeout ساخته میشد و پیشفرضِ ۵
ثانیهایِ کتابخانه ingestِ کتابِ هشتم را روی اولین فراخوانی کشت.
• پاکسازیِ نقاطِ نیمهنوشته: upsert با wait=True ممکن است نوشته باشد و فقط
جوابش تایماوت شده باشد؛ مسیرِ جبران فقط ردیفهای DB را پس میگرفت و هر
retry یک نسخهی تکراری در Qdrant جا میگذاشت (۸ مورد در پایه نهم، پاک شد).
• تشخیصِ سقفِ حساب: «insufficient user quota» با فاصله میآید نه زیرخط، و
محافظِ اول نمیگرفتش — یک اجرا ۷۷۰ فراخوانیِ محکومبهشکست زد.
• پرامپتِ vision حالا میخواهد محتوای شکل داخلِ متنِ مسئله نوشته شود. ۴۲٪ از
محتوای موجود به شکلی ارجاع میدهد که در متن نیست؛ مدل آن را دیده بود ولی
از او خواسته نشده بود بنویسدش. کتابهای موجود قبل از این قاعده وارد شدهاند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
778 lines
35 KiB
Python
778 lines
35 KiB
Python
from django.core.cache import cache
|
||
from django.db import IntegrityError, transaction
|
||
from django.db.models import Count
|
||
from django.http import Http404
|
||
import random
|
||
|
||
from django.shortcuts import get_object_or_404
|
||
from drf_spectacular.types import OpenApiTypes
|
||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||
from rest_framework import status
|
||
from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError
|
||
from rest_framework.permissions import IsAuthenticated
|
||
from rest_framework.response import Response
|
||
from rest_framework.throttling import ScopedRateThrottle
|
||
from rest_framework.views import APIView
|
||
|
||
from progress.models import UserHearts, UserStats
|
||
from subscription.services import has_unlimited_hearts
|
||
|
||
from .models import (
|
||
Choice, Grade, Lesson, Node, NodeProgress, Question, QuestionReport, UserAnswer,
|
||
UserLessonCompletion,
|
||
)
|
||
from .serializers import (
|
||
AnswerRequestSerializer,
|
||
AnswerResponseSerializer,
|
||
GradesListResponseSerializer,
|
||
HomeResponseSerializer,
|
||
LessonCompleteRequestSerializer,
|
||
LessonCompleteResponseSerializer,
|
||
LessonIntroResponseSerializer,
|
||
LessonQuestionsResponseSerializer,
|
||
NodeLessonsResponseSerializer,
|
||
NodeQuizResponseSerializer,
|
||
NodeProgressListResponseSerializer,
|
||
QuestionReportRequestSerializer,
|
||
QuestionReportResponseSerializer,
|
||
)
|
||
|
||
|
||
class GuestAwareContentThrottle(ScopedRateThrottle):
|
||
"""
|
||
مشترک با GuestAwareAIThrottle/GuestAwareFamilyThrottle/GuestAwareDiscountThrottle
|
||
و مشابههاش: چون GuestSessionView با هر deviceId جدید یک pk تازه میسازه،
|
||
throttleِ استانداردِ بر اساسِ user.pk با چرخشِ حسابِ مهمان دور زده میشه —
|
||
درحالیکه answer/complete اثرِ جانبیِ ماندگار (هارت، xp/coins/progress) دارن.
|
||
برای همین فقط کاربرهای مهمان با IP کلید میخورن؛ کاربرهای غیرمهمان مثلِ قبل بر اساسِ pk.
|
||
"""
|
||
|
||
def get_cache_key(self, request, view):
|
||
user = request.user
|
||
if user and user.is_authenticated and getattr(user, 'is_guest', False):
|
||
ident = f'guest-ip:{self.get_ident(request)}'
|
||
elif user and user.is_authenticated:
|
||
ident = user.pk
|
||
else:
|
||
ident = self.get_ident(request)
|
||
|
||
return self.cache_format % {'scope': self.scope, 'ident': ident}
|
||
|
||
|
||
# --- کشِ دادهی شبهِثابتِ برنامهی درسی -------------------------------------
|
||
# گریدها و درختِ unit/node/lesson تقریباً هیچوقت عوض نمیشوند ولی روی هر بار
|
||
# باز شدنِ اپ خوانده میشوند. یک نسخهکلیدِ مشترک نگه میداریم که با هر تغییرِ
|
||
# محتوا (سیگنال در content/receivers.py) یک واحد جلو میرود و همهی کلیدهای
|
||
# کشِ محتوا را یکجا باطل میکند. دیتای per-user (پیشرفت/آمار) کش نمیشود.
|
||
_CURRICULUM_VERSION_KEY = 'content:curriculum:ver'
|
||
_CACHE_TTL = 3600
|
||
|
||
|
||
def curriculum_version() -> int:
|
||
return cache.get_or_set(_CURRICULUM_VERSION_KEY, 1, None) or 1
|
||
|
||
|
||
def bump_curriculum_version() -> None:
|
||
try:
|
||
cache.incr(_CURRICULUM_VERSION_KEY)
|
||
except ValueError:
|
||
cache.set(_CURRICULUM_VERSION_KEY, curriculum_version() + 1, None)
|
||
|
||
|
||
def _grades_payload() -> dict:
|
||
key = f'content:grades:v{curriculum_version()}'
|
||
|
||
def build():
|
||
# lessonCount در یک کوئری میآید؛ بدونش فرانت نمیفهمد کدام پایه
|
||
# هنوز خالی است و کاربر صفحهی خالی میبیند.
|
||
grades = Grade.objects.annotate(
|
||
_lessons=Count('units__nodes__lessons', distinct=True),
|
||
).order_by('id')
|
||
return {'grades': [
|
||
{'id': g.id, '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)
|
||
|
||
|
||
# پالت و آیکونِ پیشفرضِ کارتِ مبحث — هر دو از خودِ اپ گرفته شدهاند
|
||
# (رنگهای موجود در طرح، و آیکونهایی که از 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 یعنی گریدِ ناموجود؛ [] یعنی گریدِ موجودِ بدونِ واحد."""
|
||
key = f'content:tree:grade:{grade_id}:v{curriculum_version()}'
|
||
cached = cache.get(key)
|
||
if cached is not None:
|
||
return None if cached is False else cached
|
||
|
||
if not Grade.objects.filter(pk=grade_id).exists():
|
||
cache.set(key, False, _CACHE_TTL)
|
||
return None
|
||
|
||
units = (
|
||
Grade.objects.get(pk=grade_id)
|
||
.units.prefetch_related('nodes__lessons').all()
|
||
)
|
||
tree = []
|
||
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()],
|
||
})
|
||
position += 1
|
||
tree.append({
|
||
'id': str(unit.id),
|
||
'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 = [GuestAwareContentThrottle]
|
||
|
||
@extend_schema(responses=GradesListResponseSerializer, tags=['Curriculum (content)'])
|
||
def get(self, request):
|
||
return Response(GradesListResponseSerializer(_grades_payload()).data)
|
||
|
||
|
||
def _node_status(lesson_ids, completed_ids, is_first_unlocked_candidate):
|
||
if not lesson_ids:
|
||
return 'locked'
|
||
done = sum(1 for lid in lesson_ids if lid in completed_ids)
|
||
if done >= len(lesson_ids):
|
||
return 'completed'
|
||
if done > 0 or is_first_unlocked_candidate:
|
||
return 'unlocked'
|
||
return 'locked'
|
||
|
||
|
||
class HomeView(APIView):
|
||
"""GET /api/home?grade — نقشهی مهارتها + آمار (ادغامشده)"""
|
||
|
||
# userId همیشه از request.user.id (توکن معتبر) گرفته میشود، نه ورودی کلاینت (IDOR).
|
||
permission_classes = [IsAuthenticated]
|
||
throttle_scope = 'read'
|
||
throttle_classes = [GuestAwareContentThrottle]
|
||
|
||
@extend_schema(
|
||
parameters=[
|
||
OpenApiParameter('gradeId', OpenApiTypes.INT, required=False,
|
||
description='پایهای که نقشهاش خواسته میشود؛ '
|
||
'بدونِ آن، پایهی ذخیرهشدهی خودِ کاربر.'),
|
||
OpenApiParameter('grade', OpenApiTypes.INT, required=False,
|
||
deprecated=True, description='نامِ قدیمیِ gradeId.'),
|
||
],
|
||
responses=HomeResponseSerializer,
|
||
tags=['Curriculum (content)'],
|
||
)
|
||
def get(self, request):
|
||
user_id = request.user.id
|
||
|
||
# ⚠️ پیشتر فقط پارامترِ صریحِ ?grade= خوانده میشد و پایهی ذخیرهشدهی
|
||
# خودِ کاربر نادیده میماند؛ نتیجه این بود که اپ در «آشنایی» پایه را ثبت
|
||
# میکرد و بعد صفحهی خانه خالی برمیگشت. حالا پارامتر اولویت دارد (برای
|
||
# مرورِ پایهی دیگر) و در نبودش پایهی خودِ کاربر ملاک است.
|
||
# هر دو نام پذیرفته میشوند. نامِ درست `gradeId` است — با بقیهی API
|
||
# یکی است (profile و /user/grade هم gradeId میگویند) — ولی نسخهی
|
||
# اولِ این اندپوینت `grade` بود و کلاینتهایی با همان بیرون رفتهاند.
|
||
# پذیرفتنِ هر دو ارزان است؛ نپذیرفتن یعنی انتخابِ پایه بیصدا نادیده
|
||
# گرفته میشود و کاربر نقشهی پایهی دیگری را میبیند.
|
||
grade_param = (request.query_params.get('gradeId')
|
||
or request.query_params.get('grade'))
|
||
if grade_param:
|
||
try:
|
||
grade_id = int(grade_param)
|
||
except (TypeError, ValueError):
|
||
raise Http404
|
||
else:
|
||
grade_id = request.user.grade_id
|
||
|
||
tree = []
|
||
if grade_id:
|
||
tree = _grade_tree(grade_id)
|
||
if tree is None:
|
||
# پایهای که کاربر ذخیره کرده ممکن است بعداً حذف شده باشد؛ آن
|
||
# حالت خطا نیست، فقط نقشهی خالی است. ولی ?grade=ی که کاربر
|
||
# صریحاً خواسته و وجود ندارد، ۴۰۴ است.
|
||
if grade_param:
|
||
raise Http404
|
||
tree = []
|
||
|
||
# همهی lesson_idهای کاملشدهی این کاربر در همین گرید — یک کوئری،
|
||
# مستقل از تعدادِ node (رفعِ N+1).
|
||
all_lesson_ids = [lid for unit in tree for node in unit['nodes'] for lid in node['lesson_ids']]
|
||
completed_ids = set()
|
||
if all_lesson_ids:
|
||
completed_ids = set(
|
||
UserLessonCompletion.objects.filter(
|
||
user_id=user_id, lesson_id__in=all_lesson_ids
|
||
).values_list('lesson_id', flat=True)
|
||
)
|
||
|
||
units_payload = []
|
||
for unit in tree:
|
||
node_payload = []
|
||
unlock_next = True # اولین نودِ قفلنشده در هر واحد، باز میمونه
|
||
for node in unit['nodes']:
|
||
node_status = _node_status(node['lesson_ids'], completed_ids, unlock_next)
|
||
if node_status != 'completed':
|
||
unlock_next = False
|
||
node_payload.append({
|
||
'id': node['id'],
|
||
'title': node['title'],
|
||
'icon': node.get('icon', ''),
|
||
'color': node.get('color', ''),
|
||
'status': node_status,
|
||
'lessonIds': node['lesson_ids'],
|
||
})
|
||
units_payload.append({
|
||
'id': unit['id'],
|
||
'title': unit['title'],
|
||
'blurb': unit.get('blurb', ''),
|
||
'nodes': node_payload,
|
||
})
|
||
|
||
stats = UserStats.objects.filter(user_id=user_id).first()
|
||
stats_payload = {
|
||
'progressPercent': stats.progress_percent if stats else 0,
|
||
'xp': stats.xp if stats else 0,
|
||
'coins': stats.coins if stats else 0,
|
||
}
|
||
|
||
data = {'units': units_payload, 'stats': stats_payload}
|
||
return Response(HomeResponseSerializer(data).data)
|
||
|
||
|
||
class LessonIntroView(APIView):
|
||
"""GET /api/lessons/{id}/intro"""
|
||
|
||
@extend_schema(responses=LessonIntroResponseSerializer, tags=['Curriculum (content)'])
|
||
def get(self, request, lesson_id):
|
||
lesson = get_object_or_404(Lesson, pk=lesson_id)
|
||
data = {
|
||
'lessonId': str(lesson.id),
|
||
'title': lesson.title,
|
||
'kind': lesson.kind,
|
||
'introText': lesson.intro_text,
|
||
'example': lesson.example,
|
||
'steps': lesson.steps 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"""
|
||
|
||
@extend_schema(responses=LessonQuestionsResponseSerializer, tags=['Curriculum (content)'])
|
||
def get(self, request, lesson_id):
|
||
lesson = get_object_or_404(Lesson, pk=lesson_id)
|
||
questions = lesson.questions.prefetch_related('choices').all()
|
||
data = {'questions': [
|
||
{
|
||
'id': str(q.id),
|
||
'type': q.type,
|
||
'prompt': q.prompt,
|
||
'options': [{'id': str(c.id), 'value': c.value} for c in q.choices.all()],
|
||
}
|
||
for q in questions
|
||
]}
|
||
return Response(LessonQuestionsResponseSerializer(data).data)
|
||
|
||
|
||
class QuestionAnswerView(APIView):
|
||
"""POST /api/questions/{id}/answer — ثبت پاسخ"""
|
||
|
||
# userId همیشه از request.user.id گرفته میشود، نه ورودی کلاینت.
|
||
permission_classes = [IsAuthenticated]
|
||
# Idempotency-Key فقط جلوی retry با همون کلید رو میگیره، نه حجمِ درخواستهای
|
||
# *متفاوت* که هرکدوم UserAnswer جدید میسازن و هارت/NodeProgress رو تغییر میدن؛
|
||
# برای همون GuestAwareContentThrottle لازمه (نگاه کنید به تعریفش بالا).
|
||
throttle_scope = 'question_answer'
|
||
throttle_classes = [GuestAwareContentThrottle]
|
||
|
||
@extend_schema(
|
||
request=AnswerRequestSerializer,
|
||
responses=AnswerResponseSerializer,
|
||
parameters=[
|
||
OpenApiParameter(
|
||
'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False,
|
||
description=(
|
||
'کلید اختیاری idempotency (مثل payment و lesson/complete). اگه ست '
|
||
'بشه، رتری/دابلکلیکِ همون submit با همون کلید، دوباره ردیفِ پاسخ/'
|
||
'کسرِ هارتِ تکراری نمیسازه و نتیجهی همون پاسخِ قبلاً ثبتشده '
|
||
'برگردونده میشه.'
|
||
),
|
||
),
|
||
],
|
||
tags=['Curriculum (content)'],
|
||
)
|
||
def post(self, request, question_id):
|
||
question = get_object_or_404(Question, pk=question_id)
|
||
serializer = AnswerRequestSerializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
selected_option_id = serializer.validated_data['selectedOptionId']
|
||
|
||
choice = get_object_or_404(Choice, pk=selected_option_id, question=question)
|
||
correct_choice = question.choices.filter(is_correct=True).first()
|
||
|
||
user_id = request.user.id
|
||
xp_earned = 10 if choice.is_correct else 0
|
||
idempotency_key = request.headers.get('Idempotency-Key') or None
|
||
|
||
# درخواست تکراری با همین کلید: نتیجهی قبلی بدون کسر دوبارهی هارت برگردانده میشود.
|
||
if idempotency_key:
|
||
existing = UserAnswer.objects.filter(
|
||
user_id=user_id, question=question, idempotency_key=idempotency_key,
|
||
).first()
|
||
if existing is not None:
|
||
hearts = UserHearts.objects.filter(user_id=user_id).first()
|
||
data = {
|
||
'correct': existing.correct,
|
||
'correctOptionId': str(correct_choice.id) if correct_choice else '',
|
||
'xpEarned': existing.xp_earned,
|
||
'heartsRemaining': hearts.current_hearts if hearts else 5,
|
||
'mascotMood': 'happy' if existing.correct else 'encourage',
|
||
}
|
||
return Response(AnswerResponseSerializer(data).data)
|
||
|
||
# اتمیک: ثبت پاسخ + کسر هارت باید یک واحد باشند. UserHearts زیر قفل
|
||
# خوانده/کم میشود تا lost update در پاسخهای همزمان رخ ندهد. قید یکتاییِ
|
||
# سطح DB (uniq_user_answer_idempotency_key) جلوی ثبت دوباره با همان کلید را میگیرد.
|
||
try:
|
||
with transaction.atomic():
|
||
UserAnswer.objects.create(
|
||
user_id=user_id,
|
||
question=question,
|
||
choice=choice,
|
||
correct=choice.is_correct,
|
||
xp_earned=xp_earned,
|
||
idempotency_key=idempotency_key,
|
||
)
|
||
if not choice.is_correct:
|
||
UserHearts.objects.get_or_create(user_id=user_id)
|
||
hearts = UserHearts.objects.select_for_update().get(user_id=user_id)
|
||
hearts.apply_regen()
|
||
if not has_unlimited_hearts(user_id):
|
||
hearts.lose_heart()
|
||
hearts.save(update_fields=['current_hearts', 'next_refill_at'])
|
||
hearts_remaining = hearts.current_hearts
|
||
else:
|
||
hearts = UserHearts.objects.filter(user_id=user_id).first()
|
||
hearts_remaining = hearts.current_hearts if hearts else 5
|
||
except IntegrityError:
|
||
if not idempotency_key:
|
||
raise
|
||
existing = UserAnswer.objects.filter(
|
||
user_id=user_id, question=question, idempotency_key=idempotency_key,
|
||
).first()
|
||
if existing is None:
|
||
raise
|
||
choice = existing.choice
|
||
xp_earned = existing.xp_earned
|
||
# قفل ردیف UserHearts هم اینجا رعایت میشود تا این خواندنِ fallback با تغییرات همزمان race نکند.
|
||
with transaction.atomic():
|
||
hearts = UserHearts.objects.select_for_update().filter(user_id=user_id).first()
|
||
hearts_remaining = hearts.current_hearts if hearts else 5
|
||
|
||
data = {
|
||
'correct': choice.is_correct,
|
||
'correctOptionId': str(correct_choice.id) if correct_choice else '',
|
||
'xpEarned': xp_earned,
|
||
'heartsRemaining': hearts_remaining if hearts_remaining is not None else 5,
|
||
'mascotMood': 'happy' if choice.is_correct else 'encourage',
|
||
}
|
||
return Response(AnswerResponseSerializer(data).data)
|
||
|
||
|
||
class LessonCompleteView(APIView):
|
||
"""POST /api/lessons/{id}/complete — اتمام درس + آمار جدید"""
|
||
|
||
# userId همیشه از request.user.id گرفته میشود (نه ورودیِ کلاینت)، وگرنه کلاینت
|
||
# میتونست بهجای هر کاربرِ دیگری xp/coins ثبت کنه.
|
||
permission_classes = [IsAuthenticated]
|
||
# Idempotency-Key فقط جلوی retry با همون کلید رو میگیره؛ برای همینه که
|
||
# GuestAwareContentThrottle لازمه (نگاه کنید به تعریفش بالا). نرخ ۱۰/hour
|
||
# (نه ۶۰/min مثلِ question_answer) چون این endpoint رکوردِ ماندگار میسازه.
|
||
throttle_scope = 'lesson_complete'
|
||
throttle_classes = [GuestAwareContentThrottle]
|
||
|
||
@extend_schema(
|
||
request=LessonCompleteRequestSerializer,
|
||
responses=LessonCompleteResponseSerializer,
|
||
parameters=[
|
||
OpenApiParameter(
|
||
'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False,
|
||
description=(
|
||
'کلید اختیاری idempotency (مثل payment). اگه ست بشه، رتری/'
|
||
'دابلکلیکِ همون submit با همون کلید، دوباره ردیف/جایزهی '
|
||
'تکراری نمیسازه و نتیجهی همون completion موجود برگردونده میشه.'
|
||
),
|
||
),
|
||
],
|
||
tags=['Curriculum (content)'],
|
||
)
|
||
def post(self, request, lesson_id):
|
||
lesson = get_object_or_404(Lesson, pk=lesson_id)
|
||
serializer = LessonCompleteRequestSerializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
correct_count = serializer.validated_data['correctCount']
|
||
# totalQuestions در بدنهی درخواست پذیرفته میشود (سازگاری با schema) اما در محاسبه استفاده نمیشود.
|
||
|
||
# correctCount نباید از تعداد واقعی سوالهای این درس بیشتر باشد، تا xp/coins متورم نگیرد.
|
||
actual_question_count = lesson.questions.count()
|
||
if correct_count > actual_question_count:
|
||
raise ValidationError({
|
||
'correctCount': (
|
||
'correctCount نمیتواند از تعداد واقعی سوالهای این درس '
|
||
f'({actual_question_count}) بیشتر باشد.'
|
||
)
|
||
})
|
||
|
||
# ratio همیشه بر اساس actual_question_count محاسبه میشود، نه totalQuestions ارسالی کلاینت.
|
||
ratio = correct_count / (actual_question_count or 1)
|
||
stars = 3 if ratio >= 0.9 else 2 if ratio >= 0.6 else 1 if ratio > 0 else 0
|
||
potential_xp = correct_count * 10
|
||
potential_coins = stars * 5
|
||
|
||
user_id = request.user.id
|
||
idempotency_key = request.headers.get('Idempotency-Key') or None
|
||
|
||
# درخواست تکراری با همین کلید: نتیجهی قبلی idempotent برگردانده میشود.
|
||
if idempotency_key:
|
||
existing = UserLessonCompletion.objects.filter(
|
||
user_id=user_id, lesson=lesson, idempotency_key=idempotency_key,
|
||
).first()
|
||
if existing is not None:
|
||
stats = UserStats.objects.filter(user_id=user_id).first()
|
||
stats_payload = {
|
||
'progressPercent': stats.progress_percent if stats else 0,
|
||
'xp': stats.xp if stats else 0,
|
||
'coins': stats.coins if stats else 0,
|
||
}
|
||
data = {
|
||
'stars': existing.stars,
|
||
'xpEarned': existing.xp_earned,
|
||
'coinsEarned': existing.coins_earned,
|
||
'stats': stats_payload,
|
||
}
|
||
return Response(LessonCompleteResponseSerializer(data).data)
|
||
|
||
# atomic لازمه چون create پایینتر سیگنالِ post_save رو تریگر میکنه
|
||
# (NodeProgress) و UserStats هم آپدیت میشه؛ بدون اون، fail شدنِ save
|
||
# روی stats یعنی تکمیل ثبت شده ولی xp/coins یکبارمصرف از دست رفته.
|
||
# ردیفِ UserStats قفل میشه (select_for_update) و چکِ is_first_completion
|
||
# داخلِ همون قفل انجام میشه تا دو درخواستِ همزمان (دابلکلیک) هردو
|
||
# جایزهی کاملِ اول رو نگیرن.
|
||
try:
|
||
with transaction.atomic():
|
||
UserStats.objects.get_or_create(user_id=user_id)
|
||
stats = UserStats.objects.select_for_update().get(user_id=user_id)
|
||
|
||
is_first_completion = not UserLessonCompletion.objects.filter(
|
||
user_id=user_id, lesson=lesson
|
||
).exists()
|
||
xp_earned = potential_xp if is_first_completion else 0
|
||
coins_earned = potential_coins if is_first_completion else 0
|
||
|
||
UserLessonCompletion.objects.create(
|
||
user_id=user_id,
|
||
lesson=lesson,
|
||
correct_count=correct_count,
|
||
stars=stars,
|
||
xp_earned=xp_earned,
|
||
coins_earned=coins_earned,
|
||
idempotency_key=idempotency_key,
|
||
)
|
||
|
||
stats.xp += xp_earned
|
||
stats.coins += coins_earned
|
||
|
||
total_lessons = Lesson.objects.count() or 1
|
||
completed_lessons = UserLessonCompletion.objects.filter(
|
||
user_id=user_id
|
||
).values('lesson_id').distinct().count()
|
||
stats.progress_percent = round(min(completed_lessons / total_lessons, 1.0) * 100, 2)
|
||
stats.save(update_fields=['xp', 'coins', 'progress_percent'])
|
||
except IntegrityError:
|
||
# قید یکتاییِ سطح DB جلوی درخواستهای همزمان با همان کلید را میگیرد؛
|
||
# نتیجهی completion رقیب idempotent برگردانده میشود.
|
||
if not idempotency_key:
|
||
raise
|
||
existing = UserLessonCompletion.objects.filter(
|
||
user_id=user_id, lesson=lesson, idempotency_key=idempotency_key,
|
||
).first()
|
||
if existing is None:
|
||
raise
|
||
stats = UserStats.objects.filter(user_id=user_id).first()
|
||
stars = existing.stars
|
||
xp_earned = existing.xp_earned
|
||
coins_earned = existing.coins_earned
|
||
|
||
stats_payload = {
|
||
'progressPercent': stats.progress_percent if stats else 0,
|
||
'xp': stats.xp if stats else 0,
|
||
'coins': stats.coins if stats else 0,
|
||
}
|
||
|
||
data = {
|
||
'stars': stars,
|
||
'xpEarned': xp_earned,
|
||
'coinsEarned': coins_earned,
|
||
'stats': stats_payload,
|
||
}
|
||
return Response(LessonCompleteResponseSerializer(data).data)
|
||
|
||
|
||
class NodeProgressListView(APIView):
|
||
"""
|
||
GET /api/progress/nodes?userId=&unit= — لیست وضعیتِ کاربر روی هر Node.
|
||
|
||
توکن اجباری است. userId قابل قبول است (والد میتواند پیشرفت فرزندش را ببیند)،
|
||
اما فقط اگر برابر خودِ request.user باشد یا درخواستدهنده والدِ تأییدشدهی
|
||
همان کاربر باشد (FamilyLink با status='accepted')؛ در غیر این صورت 403.
|
||
"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
@extend_schema(
|
||
parameters=[
|
||
OpenApiParameter('userId', OpenApiTypes.INT, required=False),
|
||
OpenApiParameter('unit', OpenApiTypes.INT, required=False),
|
||
],
|
||
responses=NodeProgressListResponseSerializer,
|
||
tags=['Curriculum (content)'],
|
||
)
|
||
def get(self, request):
|
||
requested_user_id = request.query_params.get('userId')
|
||
user_id = int(requested_user_id) if requested_user_id else request.user.id
|
||
|
||
if user_id != request.user.id:
|
||
from family.models import FamilyLink
|
||
|
||
is_verified_parent = FamilyLink.objects.filter(
|
||
parent_user_id=request.user.id, child_user_id=user_id, status='accepted',
|
||
).exists()
|
||
if not is_verified_parent:
|
||
raise PermissionDenied('اجازهی مشاهدهی پیشرفتِ این کاربر را ندارید.')
|
||
|
||
qs = NodeProgress.objects.filter(user_id=user_id).select_related('node')
|
||
unit_id = request.query_params.get('unit')
|
||
if unit_id:
|
||
qs = qs.filter(node__unit_id=unit_id)
|
||
|
||
data = {'progress': list(qs.order_by('node__unit_id', 'node__order'))}
|
||
return Response(NodeProgressListResponseSerializer(data).data)
|
||
|
||
|
||
class QuestionReportView(APIView):
|
||
"""
|
||
POST /api/questions/{id}/report — «این سوال ایراد دارد»
|
||
|
||
چرا لازم بود: تنها بازخوردی که تا امروز جمع میشد مالِ چتِ دستیار بود.
|
||
روی خودِ سوالهای مسیرِ آموزشی هیچ راهی نبود که کاربر بگوید گزینهی درست
|
||
غلط است یا صورتِ سوال گنگ — و کسی که محتوا را نگه میدارد هیچ سیگنالی
|
||
نداشت جز حدس.
|
||
"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
throttle_scope = 'question_report'
|
||
throttle_classes = [ScopedRateThrottle]
|
||
|
||
@extend_schema(
|
||
request=QuestionReportRequestSerializer,
|
||
responses=QuestionReportResponseSerializer,
|
||
tags=['محتوا (content)'],
|
||
)
|
||
def post(self, request, question_id: int):
|
||
question = Question.objects.filter(pk=question_id).first()
|
||
if question is None:
|
||
raise NotFound('سوال یافت نشد.')
|
||
|
||
serializer = QuestionReportRequestSerializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
|
||
# گزارشِ بازِ تکراریِ همان کاربر روی همان سوال، ردیفِ تازه نمیسازد:
|
||
# دابلکلیک نباید صف را شلوغ کند و آمار را بههم بزند.
|
||
report, created = QuestionReport.objects.get_or_create(
|
||
question=question,
|
||
user_id=request.user.id,
|
||
status='open',
|
||
defaults={
|
||
'reason': serializer.validated_data['reason'],
|
||
'note': serializer.validated_data.get('note', ''),
|
||
},
|
||
)
|
||
return Response(
|
||
QuestionReportResponseSerializer({
|
||
'success': True,
|
||
'reportId': report.pk,
|
||
'alreadyReported': not created,
|
||
}).data,
|
||
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
|
||
)
|
||
|
||
|
||
class NodeQuizView(APIView):
|
||
"""
|
||
GET /api/nodes/{id}/quiz — یک آزمونِ آماده برای کلِ مبحث.
|
||
|
||
چرا لازم شد: واحدِ بازی در اپ «مبحث» است (همان جزیرهی روی نقشه)، ولی
|
||
سوالها به «درس» بستهاند و درس در این پروژه ریزترین دانه است — یک نوعِ
|
||
مسئلهی استخراجشده از کتاب، نه یک جلسهی درسی. نتیجهاش این بود که هر
|
||
درس یکی-دو سوال داشت و اپ برای ساختنِ یک آزمونِ چهارسوالی باید دهها
|
||
درخواستِ `lessons/{id}/questions` میزد و خودش سرِ هم میکرد.
|
||
|
||
این اندپوینت همان کار را یکجا میکند: از همهی درسهای مبحث نمونه
|
||
برمیدارد و یک آزمون میدهد.
|
||
|
||
ترتیب تصادفی نیست: `?seed=` یا شناسهی کاربر مبنای درهمریزی است تا
|
||
دانشآموزی که صفحه را رفرش میکند همان آزمون را ببیند، نه مجموعهی تازهای
|
||
که پیشرفتش را بیمعنا کند.
|
||
"""
|
||
|
||
throttle_scope = 'read'
|
||
throttle_classes = [GuestAwareContentThrottle]
|
||
|
||
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,
|
||
)
|
||
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 NodeLessonsView(APIView):
|
||
"""
|
||
GET /api/nodes/{id}/lessons — قدمهای یک مبحث برای نقشهی مهارت.
|
||
|
||
چرا لازم بود: /api/home کلِ درخت را میدهد ولی فقط شناسهی درسها را؛
|
||
صفحهی نقشهی مهارت برای هر قدم عنوان، نوع (آزمون/ویدیو)، زیرنویس و
|
||
«انجام شد یا نه» میخواهد. بدونِ این، فرانت باید برای هر درس یک
|
||
درخواستِ intro جدا میزد — یعنی دهها درخواست برای یک صفحه.
|
||
"""
|
||
|
||
throttle_scope = 'read'
|
||
throttle_classes = [GuestAwareContentThrottle]
|
||
|
||
@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)
|