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

@@ -0,0 +1,96 @@
"""
جزیره‌ها روی نقشه باید با بازی کردنِ درس‌ها واقعاً تمام شوند (۲۰۲۶-۰۹-۲۵).
دو چیز با هم جلوی پیشرفت را گرفته بود:
۱. جزیره فقط وقتی تمام می‌شد که *همه‌ی* درس‌هایش تمام شده باشند، ولی ۵۸۵ درس
هنوز سوال ندارند و اپ برایشان دکمه‌ی شروع ندارد — ۱۸۱ جزیره از ۲۶۹.
۲. فرانت با کلیک روی جزیره همیشه lessonIds[0] را باز می‌کرد، یعنی درسِ اول
را بارها و بارها.
"""
from django.core.cache import cache
from django.test import TestCase
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from .models import Choice, Grade, Lesson, Node, Question, Unit
class IslandProgressTests(TestCase):
def setUp(self):
cache.clear()
grade = Grade.objects.create(title='پایه نهم', icon='g', color='#fff')
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
self.first = Node.objects.create(unit=unit, title='جزیره‌ی اول', order=1)
self.second = Node.objects.create(unit=unit, title='جزیره‌ی دوم', order=2)
self.a = self._lesson(self.first, 'الف', with_question=True)
self.empty = self._lesson(self.first, 'بی‌سوال', with_question=False)
self.b = self._lesson(self.first, 'ب', with_question=True)
self._lesson(self.second, 'ج', with_question=True)
user = User.objects.create(phone='09120000950', grade_id=grade.id)
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
def tearDown(self):
cache.clear()
def _lesson(self, node, title, *, with_question):
lesson = Lesson.objects.create(node=node, title=title, intro_text='x', mascot_animation='x')
if with_question:
self._question(lesson)
return lesson
def _question(self, lesson):
question = Question.objects.create(lesson=lesson, type='multiple_choice', prompt='؟')
Choice.objects.create(question=question, value='۱', is_correct=True)
Choice.objects.create(question=question, value='۲', is_correct=False)
def _nodes(self):
units = self.client.get('/api/home').json()['units']
return {n['title']: n for n in units[0]['nodes']}
def _complete(self, lesson):
response = self.client.post(f'/api/lessons/{lesson.id}/complete',
{'correctCount': 1, 'totalQuestions': 1}, format='json')
self.assertEqual(response.status_code, 200, response.content)
def test_tapping_the_island_opens_the_next_unfinished_lesson(self):
nodes = self._nodes()
self.assertEqual(nodes['جزیره‌ی اول']['nextLessonId'], self.a.id)
self.assertEqual(nodes['جزیره‌ی اول']['lessonIds'][0], self.a.id)
self._complete(self.a)
first = self._nodes()['جزیره‌ی اول']
# درسِ بی‌سوال رد می‌شود؛ lessonIds[0] همان است که فرانت باز می‌کند.
self.assertEqual(first['nextLessonId'], self.b.id)
self.assertEqual(first['lessonIds'][0], self.b.id)
self.assertCountEqual(first['lessonIds'], [self.a.id, self.empty.id, self.b.id])
def test_a_lesson_without_questions_does_not_hold_the_island(self):
self._complete(self.a)
self._complete(self.b)
nodes = self._nodes()
self.assertEqual(nodes['جزیره‌ی اول']['status'], 'completed')
self.assertEqual(nodes['جزیره‌ی دوم']['status'], 'unlocked')
# همه‌ی درس‌ها تمام شده‌اند: برای مرور، درسِ اول.
self.assertEqual(nodes['جزیره‌ی اول']['nextLessonId'], self.a.id)
def test_progress_counts_only_lessons_that_can_be_played(self):
self._complete(self.a)
percent = self.client.get('/api/home').json()['stats']['progressPercent']
self.assertAlmostEqual(percent, 33.33, places=2) # ۱ از ۳ درسِ سوال‌دار
def test_new_content_is_offered_but_progress_never_goes_backwards(self):
self._complete(self.a)
self._complete(self.b)
self.assertEqual(self._nodes()['جزیره‌ی اول']['status'], 'completed')
self._question(self.empty) # حالا این درس هم بازی‌شدنی است
nodes = self._nodes()
# نقشه‌ی کش‌شده باطل شد: درسِ تازه پیشنهاد می‌شود…
self.assertEqual(nodes['جزیره‌ی اول']['nextLessonId'], self.empty.id)
# …ولی جزیره‌ی تمام‌شده باز و جزیره‌ی بعدی قفل نمی‌شود.
self.assertEqual(nodes['جزیره‌ی اول']['status'], 'completed')
self.assertEqual(nodes['جزیره‌ی دوم']['status'], 'unlocked')