Files
MathApp/progress/tests_weekly_report.py
Zareian 39247f6d53 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>
2026-10-02 02:38:43 +03:30

159 lines
7.6 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.
"""
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)