The Swagger header was a list of features. That is what the project does, not what a caller needs. It now opens with the three things anyone integrating against this API asks first: how do I get a token, what does an error look like, and when do I get rate limited -- plus the field-naming and date conventions, and a note that the schema is the contract and clients should be generated from it rather than written from memory. Swagger UI and Redoc both render Markdown, so it is structured rather than one long paragraph, which also fixes how badly the old mixed Persian/Latin line wrapped right-to-left. Two defects surfaced while checking the rendered schema: - /api/health was absent from the schema entirely. The trailing-slash alias was excluded with extend_schema(exclude=True)(HealthView), but that decorator mutates the class, and both routes pointed at the same class, so the canonical route disappeared with it. A subclass now carries the exclusion, and the endpoint is back, exactly once. Endpoint count is 56, not 55. - One endpoint was tagged 'محتوا (content)' while every other content endpoint used 'Curriculum (content)', which split the group into two sections. Tags are now declared with descriptions and ordered by the path a real user takes, rather than alphabetically. Comment pass over the codebase, keeping the substance and dropping the noise: the "⚠️ اضافه شده" prefixes dated comments against a moment no current reader shares; "فاز ۲ / بخش ۴" referenced a plan that no longer exists and only repeated the app name on the next line; "فاز اول - ۴ از ۱۱ اپ" had drifted (there are fourteen). INSTALLED_APPS is now grouped by responsibility. The TEST_LOGIN_PHONES block in settings shrank from 29 lines to 8 now that the same explanation lives in docs/operations.md and .env.example -- a comment beside the code should carry what a code reader needs, not a copy of the runbook. Checked mechanically that no comment references an identifier that no longer exists: zero. The dated measurements in rag/services/ingest.py and generate_questions_from_chunks.py were deliberately left alone -- a measurement carrying the date it was taken is the useful kind. Co-Authored-By: Claude Opus 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
|
||
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'])
|
||
|
||
# اعلانِ درسِ تکمیلشده. تا پیش از این، نوعِ '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)
|