Grade ids were the order the books were imported in (ninth=1, eighth=5), so gradeId: 5 meant eighth grade and the admin, the question filter and the API all listed grades out of order. Grade.number (1-12) is now read from the title, new grades take it as their id, and migration 0008 moves existing ids plus every reference to them: Unit, the loose grade_id in accounts/ai_tutor/rag, and the grade target inside campaign JSON. Unnumbered demo grades move above 100 and the id sequence is raised past 12 so they never collide with a real grade. The frontend needs no change: it always reads gradeId from the server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
797 lines
37 KiB
Python
797 lines
37 KiB
Python
from django.core.cache import cache
|
||
from django.db import IntegrityError, transaction
|
||
from django.db.models import Count, F
|
||
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(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)
|
||
|
||
|
||
# پالت و آیکونِ پیشفرضِ کارتِ مبحث — هر دو از خودِ اپ گرفته شدهاند
|
||
# (رنگهای موجود در طرح، و آیکونهایی که از 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'])
|
||
|
||
# اعلانِ درسِ تکمیلشده. تا پیش از این، نوعِ '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=f'«{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,
|
||
}
|
||
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=['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 = [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)
|