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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user