Files
MathApp/content/services.py
Zareian 42783f9ba4 tutor answers step by step in plain text, islands finish, and the audit's money and login holes are closed
Tutor (prompt tutor-v3-stepwise-plain): plain text instead of LaTeX/markdown,
with rag/services/mathtext.py converting whatever still slips through; full
step-by-step solutions with a check before the final answer; three tones by
grade; the last 6 messages of the conversation go to the model and follow-ups
are no longer served from another user's cache; book solutions the checker
disputes are kept out of the context; a questionId gives the tutor the
practice question and the student's wrong answer. /api/health reports when
the AI credit runs out. rag_model_benchmark compares chat models on verified
book problems (run on the server; credit ran out mid-run).

Map: only lessons that have questions count toward finishing an island,
lessonIds[0] / nextLessonId is always the next lesson to play, and a finished
island stays finished. The Seyar characters (sigma/delta/pi) are seeded.

Audit fixes, each reproduced before fixing:
- the plan is stored on the order, so paying a monthly order after starting
  a yearly checkout no longer grants a year
- opening checkout no longer demotes an active subscription; renewals extend
  from the current expiry; expired subscriptions are not reported active
- one discount code per order and only before the gateway; a 100% code
  completes the purchase; lost Zarinpal confirmations are re-verified
- the unused /api/payment/webhook "mark paid" endpoint is removed
- XP counts only answers recorded on the server
- 6-digit OTP, each code dies after 5 wrong guesses; POST /api/auth/logout
- a guest who signs up keeps their account and progress (test phones excluded)
- affiliate commission on the first real payment; guests excluded
- parents with several children no longer get a 500
- SQLite runs transactions IMMEDIATE, since it ignores select_for_update
- admin login is rate-limited; display names drop phone numbers and links;
  userQuestion is capped at 2000 characters
- server-side heart enforcement exists behind HEARTS_ENFORCED (off until the
  frontend handles 403 no_hearts)

Not done on purpose: rotating the server's secret key and moving to Postgres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 06:24:13 +03:30

203 lines
9.0 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:
"""تعدادِ سوال‌های متمایزی از این درس/مبحث که کاربر درست جواب داده و ثبت شده."""
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()