Files
MathApp/content/services.py
Zareian 0580aec7b0 harden OTP for real SMS and close the second audit round
Kavenegar is being switched on, so every OTP request now costs credit:
- phone numbers are normalised to 09XXXXXXXXX everywhere (login, OTP,
  family invite, TEST_LOGIN_PHONES keys) and existing rows are migrated;
  non-Iranian and landline numbers are rejected before any SMS
- cooldown and a daily per-phone cap are counted in the database, so the
  three gunicorn workers no longer triple them
- a global daily OTP SMS cap (default 300) stops SMS pumping
- per-IP rates widened for CGNAT / school Wi-Fi (env-overridable)
- provider message id is logged; SMS text warns not to share the code

Second audit round: an offer no longer shortens a longer paid plan,
Zarinpal error bodies no longer 500, island XP reaches the parent report,
a non-numeric questionId no longer 500s after the answer is generated.

596 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:28:06 +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):
"""get_or_create ایجاد می‌کند، سپس ردیف با select_for_update دوباره خوانده و قفل می‌شود
تا خواندن/تغییر/ذخیره در برابر concurrent update ایمن باشد. باید داخل transaction.atomic صدا زده شود."""
NodeProgress.objects.get_or_create(user_id=user_id, node=node)
progress = NodeProgress.objects.select_for_update().get(user_id=user_id, node=node)
return progress, False
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()