Files
MathApp/content/services.py
Zareian 2c708088e4 Spike readiness: AI gets its own slice of threads, cheaper hot paths, classroom-friendly login
- /api/ai/ask: at most AI_MAX_CONCURRENT_PER_WORKER (12 of 32 threads) answers per worker;
  beyond that 503 assistant_busy + Retry-After before any quota is reserved. Without it a
  burst of questions (3-40 s each) took every thread and the map and quizzes queued behind.
  The slot is held until the stream is actually finished or closed.
- Answer submission 21 -> 14 queries, 15.4 -> 11.0 ms: one query for all choices, locked
  get_or_create for node progress and streak, and today's streak recorded once per day
  via the cache instead of a transaction per answer.
- /api/home 12.4 -> 8.6 ms: the user's completions are read by user_id instead of an IN list
  of ~600 lesson ids; the dict is returned directly (serializer stays the Swagger contract,
  HomeMatchesItsSerializerTests keeps them equal).
- otp_login per-IP 30 -> 300/hour: a class of 30 on one school Wi-Fi filled it; guessing is
  bounded per code and per phone.
- /api/health aiConcurrency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:55:24 +03:30

209 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
منطق تغییر status روی NodeProgress. دو نقطه‌ی ورودی، هر دو از
content/receivers.py صدا زده می‌شن (نه مستقیم از views.py) تا اگه جای
دیگه‌ای هم (مثلاً یک import/bulk-completion از ai_tutor) رکورد UserAnswer
یا UserLessonCompletion ساخته بشه، وضعیت Node هم خودکار به‌روز بمونه —
دقیقاً همون الگوی signal-based که برای discount/subscription استفاده شده.
"""
from django.db import transaction
from django.db.models import Count
from django.utils import timezone
from statuslog.signals import status_changed
from .models import Lesson, Node, NodeProgress, UserLessonCompletion, UserNodeCompletion
def _get_or_create_progress(user_id, node):
"""ردیف را با قفل (select_for_update) می‌گیرد یا می‌سازد، در یک کوئری برای حالتِ معمول (ردیف هست).
دو ساختِ هم‌زمان به قیدِ یکتاییِ (user_id, node) می‌خورند و get_or_create دومی را دوباره می‌خواند.
باید داخلِ transaction.atomic صدا زده شود."""
progress, created = NodeProgress.objects.select_for_update().get_or_create(user_id=user_id, node=node)
return progress, created
def _emit_if_changed(progress, old_status, event, user_id):
if progress.status != old_status:
status_changed.send(
sender='content.NodeProgress', object_id=progress.id,
old_status=old_status, new_status=progress.status,
event=event, user_id=user_id,
)
def touch_node_progress_on_answer(user_id, question):
"""
وقتی کاربر به یک سوال پاسخ می‌ده (چه درست چه غلط)، یعنی روی Node
مربوطه فعالیت داشته. اگه Node هنوز شروع نشده یا رها شده بوده،
به in_progress برمی‌گرده.
"""
node = question.lesson.node
with transaction.atomic():
progress, _created = _get_or_create_progress(user_id, node)
old_status = progress.status
now = timezone.now()
if progress.status in ('not_started', 'abandoned'):
progress.status = 'in_progress'
if progress.started_at is None:
progress.started_at = now
progress.last_activity_at = now
progress.save(update_fields=['status', 'started_at', 'last_activity_at'])
_emit_if_changed(progress, old_status, event='answer_activity', user_id=user_id)
return progress
def playable_lesson_ids(lessons_with_counts) -> list[int]:
"""
[(lesson_id, تعدادِ سوال), ...] → idهای درس‌های سوال‌دار، به همان ترتیب.
۵۸۵ درس از ۲٬۶۲۴ (۲۰۲۶-۰۹-۲۴) هنوز سوال ندارند و اپ برایشان فقط «به‌زودی»
نشان می‌دهد، پس هیچ‌وقت تمام نمی‌شوند. اگر شرطِ تمام شدنِ جزیره «همه‌ی
درس‌ها» باشد، ۱۸۱ جزیره از ۲۶۹ هرگز تمام نمی‌شد. جزیره‌ای که هیچ درسِ
سوال‌داری ندارد، همه‌ی درس‌هایش را نگه می‌دارد (رفتارِ قبلی).
"""
pairs = list(lessons_with_counts)
playable = [lid for lid, count in pairs if count]
return playable or [lid for lid, _count in pairs]
def node_playable_lesson_ids(node) -> list[int]:
return playable_lesson_ids(
node.lessons.annotate(question_total=Count('questions'))
.values_list('id', 'question_total')
)
def verified_correct_count(user_id, *, lesson_id=None, node_id=None) -> int:
"""
تعدادِ سوال‌های متمایزی از این درس/مبحث که کاربر درست جواب داده و ثبت شده.
«درست» یعنی *هر* جوابِ درست، حتی بعد از جوابِ غلط روی همان سوال. چون
جوابِ غلط `correctOptionId` را برمی‌گرداند و تکرارِ جواب آزاد است، این عدد
جلوی «جواب نداده امتیاز گرفتن» را می‌گیرد، نه «غلط زدن و بعد درست زدن».
"""
from .models import UserAnswer
qs = UserAnswer.objects.filter(user_id=user_id, correct=True)
if lesson_id is not None:
qs = qs.filter(question__lesson_id=lesson_id)
if node_id is not None:
qs = qs.filter(question__lesson__node_id=node_id)
return qs.values('question_id').distinct().count()
def update_node_progress_on_lesson_completion(user_id, lesson):
"""
وقتی یک Lesson تکمیل می‌شه، تعداد Lessonهای تکمیل‌شده‌ی Node رو
به‌روز می‌کنه؛ اگه همه‌ی Lessonهای Node تکمیل شده باشن، status
میره روی completed، وگرنه (یا اگه از abandoned برگشته) in_progress.
"""
node = lesson.node
with transaction.atomic():
progress, _created = _get_or_create_progress(user_id, node)
old_status = progress.status
now = timezone.now()
lesson_ids = node_playable_lesson_ids(node)
completed_count = UserLessonCompletion.objects.filter(
user_id=user_id, lesson_id__in=lesson_ids
).values('lesson_id').distinct().count()
progress.lessons_completed_count = completed_count
if progress.started_at is None:
progress.started_at = now
progress.last_activity_at = now
if lesson_ids and completed_count >= len(lesson_ids):
progress.status = 'completed'
progress.completed_at = now
elif UserNodeCompletion.objects.filter(user_id=user_id, node=node).exists():
# آزمونِ مبحث قبلاً تمام شده؛ درسِ تازه نباید مبحث را به
# in_progress برگرداند.
progress.status = 'completed'
progress.completed_at = progress.completed_at or now
else:
progress.status = 'in_progress'
progress.save(update_fields=[
'lessons_completed_count', 'status', 'started_at', 'completed_at', 'last_activity_at',
])
_emit_if_changed(progress, old_status, event='lesson_completed', user_id=user_id)
return progress
def mark_node_completed_by_quiz(user_id, node):
"""پایانِ آزمونِ مبحث → NodeProgress همان مبحث completed می‌شود."""
with transaction.atomic():
progress, _created = _get_or_create_progress(user_id, node)
old_status = progress.status
now = timezone.now()
progress.status = 'completed'
progress.started_at = progress.started_at or now
progress.completed_at = progress.completed_at or now
progress.last_activity_at = now
progress.save(update_fields=['status', 'started_at', 'completed_at', 'last_activity_at'])
_emit_if_changed(progress, old_status, event='node_quiz_completed', user_id=user_id)
return progress
def quiz_completed_node_ids(user_id, node_ids):
"""از میانِ node_ids، آن‌هایی که کاربر آزمونشان را تمام کرده."""
if not node_ids:
return set()
return set(
UserNodeCompletion.objects.filter(user_id=user_id, node_id__in=node_ids)
.values_list('node_id', flat=True)
)
def grade_progress_percent(user_id, grade_id):
"""
درصدِ پیشرفت در پایه‌ی خودِ کاربر: درس‌های تمام‌شده، به‌اضافه‌ی همه‌ی
درس‌های مبحث‌هایی که آزمونشان تمام شده، تقسیم بر کلِ درس‌های پایه.
قبلاً مخرج کلِ درس‌های همه‌ی پایه‌ها بود (۲٬۶۱۷ تا)، پس دانش‌آموزی که
پایه‌ی خودش را کامل تمام می‌کرد هم حدودِ ۱۰٪ می‌دید.
"""
if not grade_id:
return 0
lesson_ids = set(playable_lesson_ids(
Lesson.objects.filter(node__unit__grade_id=grade_id)
.annotate(question_total=Count('questions')).values_list('id', 'question_total')
))
if not lesson_ids:
return 0
done = set(
UserLessonCompletion.objects.filter(user_id=user_id, lesson_id__in=lesson_ids)
.values_list('lesson_id', flat=True)
)
quiz_nodes = UserNodeCompletion.objects.filter(
user_id=user_id, node__unit__grade_id=grade_id,
).values_list('node_id', flat=True)
done |= set(Lesson.objects.filter(node_id__in=quiz_nodes).values_list('id', flat=True))
done &= lesson_ids
return round(min(len(done) / len(lesson_ids), 1.0) * 100, 2)
def next_node_after(node):
"""مبحثِ بعدی روی نقشه: بعدی در همان فصل، وگرنه اولینِ فصلِ بعد، وگرنه None."""
following = (
Node.objects.filter(unit=node.unit, order__gt=node.order).order_by('order', 'id').first()
or Node.objects.filter(unit=node.unit, order=node.order, id__gt=node.id).order_by('id').first()
)
if following:
return following
unit = node.unit
next_unit_node = (
Node.objects.filter(unit__grade_id=unit.grade_id, unit__order__gt=unit.order)
.order_by('unit__order', 'unit_id', 'order', 'id').first()
)
return next_unit_node
def node_progress_summary(node):
"""برای سریالایزر: تعداد کل Lessonهای یک Node (لازم برای lessonsTotal)."""
return node.lessons.count()