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>
This commit is contained in:
2026-09-25 06:24:13 +03:30
parent fbdb9495ee
commit 42783f9ba4
56 changed files with 2792 additions and 326 deletions

View File

@@ -1,8 +1,9 @@
import logging
from django.conf import settings
from django.core.cache import cache
from django.db import IntegrityError, transaction
from django.db.models import Count, F
from django.db.models import Count, F, Prefetch
from django.http import Http404
import random
@@ -21,7 +22,7 @@ from subscription.services import has_unlimited_hearts
from .services import (
grade_progress_percent, mark_node_completed_by_quiz, next_node_after,
quiz_completed_node_ids,
playable_lesson_ids, quiz_completed_node_ids, verified_correct_count,
)
from .models import (
Choice, Grade, Lesson, Node, NodeProgress, Question, QuestionReport, UserAnswer,
@@ -120,7 +121,8 @@ _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()}'
# tree2: هر node حالا `playable_ids` هم دارد؛ کلیدِ ساختارِ قبلی باطل شد.
key = f'content:tree2:grade:{grade_id}:v{curriculum_version()}'
cached = cache.get(key)
if cached is not None:
return None if cached is False else cached
@@ -131,7 +133,10 @@ def _grade_tree(grade_id: int):
units = (
Grade.objects.get(pk=grade_id)
.units.prefetch_related('nodes__lessons').all()
.units.prefetch_related(Prefetch(
'nodes__lessons',
queryset=Lesson.objects.annotate(question_total=Count('questions')),
)).all()
)
tree = []
position = 0 # شمارنده‌ی سراسریِ مبحث در این پایه، برای چرخشِ رنگ
@@ -144,6 +149,11 @@ def _grade_tree(grade_id: int):
'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()],
# فقط درس‌هایی که سوال دارند؛ درسِ بی‌سوال را نمی‌شود «تمام» کرد
# (اپ برایش دکمه‌ی شروع ندارد)، پس نباید جزیره را قفل نگه دارد.
'playable_ids': playable_lesson_ids(
(lsn.id, lsn.question_total) for lsn in node.lessons.all()
),
})
position += 1
tree.append({
@@ -248,6 +258,11 @@ class HomeView(APIView):
# همه‌ی lesson_idهای کامل‌شده‌ی این کاربر در همین گرید — یک کوئری،
# مستقل از تعدادِ node (رفعِ N+1).
all_lesson_ids = [lid for unit in tree for node in unit['nodes'] for lid in node['lesson_ids']]
# مخرجِ درصدِ پیشرفت: فقط درس‌های سوال‌دار (همان که جزیره را تمام می‌کند).
all_playable_ids = [
lid for unit in tree for node in unit['nodes']
for lid in (node.get('playable_ids') or node['lesson_ids'])
]
completed_ids = set()
if all_lesson_ids:
completed_ids = set(
@@ -256,8 +271,15 @@ class HomeView(APIView):
).values_list('lesson_id', flat=True)
)
quiz_done = quiz_completed_node_ids(
user_id, [int(node['id']) for unit in tree for node in unit['nodes']],
tree_node_ids = [int(node['id']) for unit in tree for node in unit['nodes']]
quiz_done = quiz_completed_node_ids(user_id, tree_node_ids)
# جزیره‌ای که یک بار تمام شده، تمام می‌ماند. بدونِ این، اضافه شدنِ اولین
# سوال به یک درسِ بی‌سوال (از ادمین) جزیره‌ی تمام‌شده را باز می‌کرد و
# جزیره‌ی بعدیِ دانش‌آموز را دوباره قفل می‌کرد — پیشرفت عقب می‌رفت.
finished = quiz_done | set(
NodeProgress.objects.filter(
user_id=user_id, node_id__in=tree_node_ids, status='completed',
).values_list('node_id', flat=True)
)
units_payload = []
@@ -265,9 +287,14 @@ class HomeView(APIView):
node_payload = []
unlock_next = True # اولین نودِ قفل‌نشده در هر واحد، باز می‌مونه
for node in unit['nodes']:
playable = node.get('playable_ids') or node['lesson_ids']
node_status = _node_status(
node['lesson_ids'], completed_ids, unlock_next,
quiz_completed=int(node['id']) in quiz_done,
playable, completed_ids, unlock_next,
quiz_completed=int(node['id']) in finished,
)
next_lesson_id = next(
(lid for lid in playable if lid not in completed_ids),
playable[0] if playable else None,
)
if node_status != 'completed':
unlock_next = False
@@ -277,7 +304,11 @@ class HomeView(APIView):
'icon': node.get('icon', ''),
'color': node.get('color', ''),
'status': node_status,
'lessonIds': node['lesson_ids'],
'lessonIds': (
[next_lesson_id] + [lid for lid in node['lesson_ids'] if lid != next_lesson_id]
if next_lesson_id is not None else node['lesson_ids']
),
'nextLessonId': next_lesson_id,
})
units_payload.append({
'id': unit['id'],
@@ -290,14 +321,15 @@ class HomeView(APIView):
# همین داده‌ها: درس‌های تمام‌شده + همه‌ی درس‌های مبحث‌هایی که آزمونشان
# تمام شده. UserStats.progress_percent فقط در لحظه‌ی تکمیل به‌روز
# می‌شود و به پایه‌ی دیگر (?gradeId=) ربطی ندارد.
done_lessons = set(completed_ids)
playable_set = set(all_playable_ids)
done_lessons = set(completed_ids) & playable_set
for unit in tree:
for node in unit['nodes']:
if int(node['id']) in quiz_done:
done_lessons.update(node['lesson_ids'])
done_lessons.update(playable_set.intersection(node['lesson_ids']))
progress_percent = (
round(min(len(done_lessons) / len(all_lesson_ids), 1.0) * 100, 2)
if all_lesson_ids else 0
round(min(len(done_lessons) / len(playable_set), 1.0) * 100, 2)
if playable_set else 0
)
stats = UserStats.objects.filter(user_id=user_id).first()
@@ -410,6 +442,24 @@ class QuestionAnswerView(APIView):
# اتمیک: ثبت پاسخ + کسر هارت باید یک واحد باشند. UserHearts زیر قفل
# خوانده/کم می‌شود تا lost update در پاسخ‌های هم‌زمان رخ ندهد. قید یکتاییِ
# سطح DB (uniq_user_answer_idempotency_key) جلوی ثبت دوباره با همان کلید را می‌گیرد.
# قلبِ صفر = نمی‌شود جواب داد (مثلِ دولینگو). بدونِ این، «قلبِ نامحدود»
# (تنها مزیتِ واقعیِ اشتراک) فقط در ظاهرِ اپ وجود داشت و با صدا زدنِ
# مستقیمِ API دور زده می‌شد. پشتِ HEARTS_ENFORCED است چون فرانتِ فعلی
# برای این ۴۰۳ هنوز صفحه‌ی «قلب‌ها تمام شد» ندارد.
if settings.HEARTS_ENFORCED and not has_unlimited_hearts(user_id):
hearts = UserHearts.objects.filter(user_id=user_id).first()
if hearts is not None:
hearts.apply_regen()
if hearts.current_hearts <= 0:
return Response(
{
'detail': 'قلب‌هایت تمام شده؛ کمی صبر کن تا دوباره پر شوند.',
'code': 'no_hearts',
'nextRefillAt': hearts.next_refill_at,
},
status=status.HTTP_403_FORBIDDEN,
)
try:
with transaction.atomic():
UserAnswer.objects.create(
@@ -501,6 +551,11 @@ class LessonCompleteView(APIView):
)
})
# امتیاز فقط برای جواب‌هایی که واقعاً ثبت شده‌اند. پیش‌تر عددِ کلاینت
# (فقط با سقفِ تعدادِ سوال‌ها) پذیرفته می‌شد، پس صدا زدنِ مستقیمِ همین
# endpoint برای هر درس، بی‌جواب دادن، امتیاز و جدولِ رتبه‌بندی را پر می‌کرد.
correct_count = min(correct_count, verified_correct_count(request.user.id, lesson_id=lesson.id))
# 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
@@ -844,6 +899,9 @@ class NodeQuizCompleteView(APIView):
'correctCount': 'correctCount نمی‌تواند از totalQuestions بیشتر باشد.',
})
# مثلِ پایانِ درس: فقط جواب‌های درستِ ثبت‌شده در همین مبحث شمرده می‌شوند.
correct_count = min(correct_count, verified_correct_count(request.user.id, node_id=node_id))
ratio = correct_count / total_questions
stars = 3 if ratio >= 0.9 else 2 if ratio >= 0.6 else 1 if ratio > 0 else 0
user_id = request.user.id