senior's post-demo changes: parent report in the student's own account, Jalali birth date, invoices, math-only tutor, priced quotas

- remove the family app from the root (separate parent accounts, invite codes, parental
  settings); progress.0006 drops its tables, permissions and migration history, and
  Profile.parent_phone, the two family notification types and ?userId= on
  /api/progress/nodes go with it
- GET /api/user/weekly-report: last 7 days next to the 7 before (estimated minutes,
  questions, accuracy, islands, tutor questions) for the "parent report" page
- profile birthDate in Jalali (accounts/jalali.py), ages 5-99, birthYear derived from it
- GET /api/payment/invoices; orders now snapshot list price and plan title
- tutor answers only math: server pre-filter, scope rule in the prompt (tutor-v11),
  refusal marker replaced with fixed text; self-harm gets a help line instead
- streamed answers now record token usage (it was always 0)
- daily tutor quota: AI_DAILY_QUOTA_FREE (default 3) vs Plan.ai_daily_quota
- delete_users management command for wiping test accounts across every app
- docs/ai-cost-and-guardrails.md: measured cost per question and guardrail results

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 02:38:43 +03:30
parent d5dbd1b23d
commit 39247f6d53
67 changed files with 1619 additions and 1140 deletions

View File

@@ -0,0 +1,158 @@
"""
GET /api/user/weekly-report — گزارشی که فرانت با عنوانِ «گزارشِ والدین» نشان می‌دهد.
هر تست یک راهِ شکستن را امتحان می‌کند: دیدنِ دیتای کاربرِ دیگر، افتادنِ فعالیت در
روز یا هفته‌ی غلط (مرزِ نیمه‌شبِ تهران)، و دو بار شمرده شدنِ درسِ تکراری.
"""
from datetime import datetime, timedelta
from django.core.cache import cache
from django.test import SimpleTestCase, TestCase
from django.utils import timezone
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from ai_tutor.models import Conversation, Message
from content.models import (
Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, UserAnswer, UserLessonCompletion,
)
from .report import estimate_minutes
class EstimateMinutesTests(SimpleTestCase):
def at(self, *minutes):
base = datetime(2026, 10, 1, 10, 0)
return [base + timedelta(minutes=m) for m in minutes]
def test_no_activity_is_zero(self):
self.assertEqual(estimate_minutes([]), 0)
def test_a_single_answer_counts_one_minute(self):
self.assertEqual(estimate_minutes(self.at(0)), 1)
def test_close_events_are_one_continuous_session(self):
self.assertEqual(estimate_minutes(self.at(0, 3, 5)), 6)
def test_a_long_break_is_not_counted_as_study(self):
# دو نشستِ جدا: ۱ + ۲ دقیقه + ۱ (شروعِ نشستِ دوم)؛ دو ساعتِ وسط شمرده نمی‌شود.
self.assertEqual(estimate_minutes(self.at(0, 2, 120)), 4)
def test_order_does_not_matter(self):
self.assertEqual(estimate_minutes(self.at(5, 0, 3)), estimate_minutes(self.at(0, 3, 5)))
class WeeklyReportApiTests(TestCase):
def setUp(self):
cache.clear()
self.user = User.objects.create(phone='09120009001', is_guest=False)
self.other = User.objects.create(phone='09120009002', is_guest=False)
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
node = Node.objects.create(unit=Unit.objects.create(grade=grade, title='فصل', order=1),
title='جزیره', order=1)
self.lesson = Lesson.objects.create(node=node, title='درس')
self.question = Question.objects.create(lesson=self.lesson, type='multiple_choice', prompt='؟')
self.right = Choice.objects.create(question=self.question, value='الف', is_correct=True)
self.wrong = Choice.objects.create(question=self.question, value='ب', is_correct=False)
def tearDown(self):
cache.clear()
def client_for(self, user):
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
return client
def answer(self, user, correct, when):
row = UserAnswer.objects.create(user_id=user.id, question=self.question,
choice=self.right if correct else self.wrong, correct=correct)
UserAnswer.objects.filter(pk=row.pk).update(created_at=when)
def report(self, user=None, **params):
response = self.client_for(user or self.user).get('/api/user/weekly-report', params)
self.assertEqual(response.status_code, 200, response.content)
return response.json()
def test_requires_a_token(self):
self.assertIn(APIClient().get('/api/user/weekly-report').status_code, (401, 403))
def test_empty_account_gets_fourteen_zero_days(self):
data = self.report()
self.assertEqual(len(data['days']), 7)
self.assertEqual(len(data['previousDays']), 7)
self.assertEqual(data['days'][-1]['date'], timezone.localdate().isoformat())
self.assertEqual(data['thisWeek']['minutes'], 0)
self.assertEqual(data['lastWeek']['activeDays'], 0)
def test_only_the_callers_own_activity_is_reported(self):
now = timezone.now()
self.answer(self.other, True, now)
self.answer(self.other, True, now)
# پارامترِ userId (مثلِ /api/progress/nodes) نادیده گرفته می‌شود.
data = self.report(userId=self.other.id)
self.assertEqual(data['thisWeek']['questions'], 0)
self.assertEqual(self.report(self.other)['thisWeek']['questions'], 2)
def test_this_week_and_last_week_are_split_at_seven_days(self):
now = timezone.now()
self.answer(self.user, True, now)
self.answer(self.user, False, now - timedelta(days=8))
self.answer(self.user, True, now - timedelta(days=20)) # بیرون از هر دو پنجره
data = self.report()
self.assertEqual((data['thisWeek']['questions'], data['thisWeek']['correct']), (1, 1))
self.assertEqual((data['lastWeek']['questions'], data['lastWeek']['correct']), (1, 0))
def test_day_follows_tehran_midnight_not_utc(self):
# ۲۲:۰۰ UTC = ۰۱:۳۰ بامدادِ روزِ بعد در تهران.
today = timezone.localdate()
yesterday_utc_night = timezone.make_aware(
datetime.combine(today - timedelta(days=1), datetime.min.time()) + timedelta(hours=22),
timezone=timezone.get_fixed_timezone(0),
)
self.answer(self.user, True, yesterday_utc_night)
data = self.report()
self.assertEqual(data['days'][-1]['questions'], 1)
self.assertEqual(data['days'][-2]['questions'], 0)
def test_repeating_a_lesson_counts_it_once(self):
for _ in range(2):
UserLessonCompletion.objects.create(user_id=self.user.id, lesson=self.lesson,
correct_count=1, stars=3, xp_earned=10)
week = self.report()['thisWeek']
self.assertEqual(week['lessons'], 1)
self.assertEqual(week['xp'], 20)
def test_tutor_questions_are_counted_but_not_its_answers(self):
conversation = Conversation.objects.create(user_id=self.user.id)
Message.objects.create(conversation=conversation, role='user', text='سوال')
Message.objects.create(conversation=conversation, role='assistant', text='جواب')
other = Conversation.objects.create(user_id=self.other.id)
Message.objects.create(conversation=other, role='user', text='سوالِ دیگری')
week = self.report()['thisWeek']
self.assertEqual(week['aiQuestions'], 1)
self.assertEqual(week['activeDays'], 1)
class NodeProgressIsOwnOnlyTests(TestCase):
"""تا ۲۰۲۶-۱۰-۰۱ والدِ وصل‌شده با ?userId= پیشرفتِ فرزند را می‌دید؛ پارامتر حذف شد."""
def setUp(self):
cache.clear()
self.user = User.objects.create(phone='09120009003', is_guest=False)
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
def test_someone_elses_id_returns_only_your_own_progress(self):
other = User.objects.create(phone='09120009004', is_guest=False)
node = Node.objects.create(
unit=Unit.objects.create(grade=Grade.objects.create(title='پایه', icon='', color=''),
title='فصل', order=1),
title='جزیره', order=1)
NodeProgress.objects.create(user_id=other.id, node=node, status='in_progress')
response = self.client.get(f'/api/progress/nodes?userId={other.id}')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['progress'], [])
def test_junk_parameter_is_not_a_server_error(self):
self.assertEqual(self.client.get('/api/progress/nodes?userId=abc').status_code, 200)