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,29 @@
# Generated by Django 5.2.17 on 2026-10-01 21:01
from django.db import migrations
# اپِ family (حسابِ جدای والد، کدِ دعوت، تنظیماتِ والدین) ۲۰۲۶-۱۰-۰۱ از ریشه حذف شد؛
# جایش «گزارشِ والدین» در حسابِ خودِ دانش‌آموز است (progress/report.py). خودِ اپ دیگر
# نصب نیست، پس جدول‌هایش این‌جا drop می‌شوند. IF EXISTS: دیتابیسِ تازه هیچ‌وقت آن‌ها را
# نداشته. داده‌ی آن‌ها نگه داشته نمی‌شود؛ پیش از migrate بکاپ بگیرید.
FAMILY_TABLES = ('family_parentalsettings', 'family_weeklyreportlog', 'family_familylink')
def forget_family_app(apps, schema_editor):
"""ردِ اپ در جدول‌های خودِ جنگو: نوع‌ها و مجوزهای ادمین، و تاریخچه‌ی migration."""
ContentType = apps.get_model('contenttypes', 'ContentType')
ContentType.objects.filter(app_label='family').delete() # مجوزها با CASCADE می‌روند
schema_editor.execute("DELETE FROM django_migrations WHERE app = 'family'")
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('progress', '0005_tedi_character'),
]
operations = [
*[migrations.RunSQL(f'DROP TABLE IF EXISTS {table}', migrations.RunSQL.noop) for table in FAMILY_TABLES],
migrations.RunPython(forget_family_app, migrations.RunPython.noop),
]

148
progress/report.py Normal file
View File

@@ -0,0 +1,148 @@
"""
گزارشِ هفتگیِ خودِ کاربر — همان چیزی که فرانت با عنوانِ «گزارشِ والدین» نشان می‌دهد.
از ۲۰۲۶-۱۰-۰۱ جای اپِ `family` را گرفته است (تصمیمِ سینیور پس از ارائه): والد
دیگر حسابِ جدا و کدِ دعوت ندارد و همین صفحه را در حسابِ خودِ دانش‌آموز می‌بیند.
همه‌چیز از فعالیتِ ثبت‌شده خوانده می‌شود و هیچ جدولِ تازه‌ای ندارد:
- `questions` / `correct`: `UserAnswer` (هر جوابِ ثبت‌شده، چه در آزمونِ درس چه جزیره).
- `xp`: درس‌ها و جزیره‌های تمام‌شده — همان تعریفی که `progress/leaderboard.py` و
`UserStats.xp` دارند؛ `UserAnswer.xp_earned` به `UserStats.xp` اضافه نمی‌شود، پس
این‌جا هم شمرده نمی‌شود.
- `aiQuestions`: پیام‌های کاربر به دستیار.
⚠️ `minutes` اندازه‌گیری نیست، **تخمین** است. اپ زمانِ حضور را نمی‌فرستد؛ زمان از
فاصله‌ی رویدادهای پشتِ سرِ هم ساخته می‌شود (پایینِ `estimate_minutes`). خواندنِ یک
درس بدونِ جواب دادن، یا فکرِ طولانی‌تر از `SESSION_GAP` روی یک سوال، شمرده نمی‌شود،
پس عدد معمولاً از زمانِ واقعی کمتر است، نه بیشتر. چرا heartbeat نه: هر کاربرِ آنلاین
هر دقیقه یک نوشتن روی SQLite می‌شد و تاریخچه‌ی قبلی هم صفر می‌ماند. فرانت عدد را
«حدود» نشان می‌دهد.
پنجره‌ها غلتان‌اند (۷ روزِ آخر تا امروز، و ۷ روزِ پیش از آن)، نه هفته‌ی تقویمی: در
شنبه‌ی هفته‌ی تقویمی هر کاربری «کمتر از هفته‌ی پیش» دیده می‌شد. لیدربردِ هفتگی هم
غلتان است.
"""
from collections import defaultdict
from datetime import datetime, time, timedelta
from django.utils import timezone
WINDOW_DAYS = 7
# رویدادهایی که فاصله‌شان از این کمتر است یک نشستِ پیوسته حساب می‌شوند. ۱۰ دقیقه
# جا برای فکر روی یک سوالِ سخت یا منتظرِ جوابِ دستیار ماندن می‌گذارد.
SESSION_GAP = timedelta(minutes=10)
# اولین رویدادِ هر نشست هم زمان برده (خواندنِ سوالِ اول)؛ بدونِ این، کسی که فقط یک
# سوال جواب داده «۰ دقیقه» می‌دید.
SESSION_START = timedelta(minutes=1)
def estimate_minutes(stamps):
"""دقیقه‌های تخمینیِ یک روز از زمانِ رویدادهایش (هر ترتیبی)."""
if not stamps:
return 0
stamps = sorted(stamps)
total = SESSION_START
for prev, cur in zip(stamps, stamps[1:]):
gap = cur - prev
total += gap if gap <= SESSION_GAP else SESSION_START
return round(total.total_seconds() / 60)
def _empty_day(day):
return {
'date': day, 'minutes': 0, 'questions': 0, 'correct': 0,
'xp': 0, 'lessons': 0, 'islands': 0, 'aiQuestions': 0,
}
def _totals(days, lesson_ids, node_ids):
total = {
key: sum(d[key] for d in days)
for key in ('minutes', 'questions', 'correct', 'xp', 'aiQuestions')
}
# درس/جزیره‌ای که دو بار تکرار شده یک بار شمرده می‌شود.
total['lessons'] = len(lesson_ids)
total['islands'] = len(node_ids)
total['activeDays'] = sum(1 for d in days if d['minutes'] > 0)
return total
def weekly_report(user_id, today=None):
from ai_tutor.models import Message
from content.models import UserAnswer, UserLessonCompletion, UserNodeCompletion
from progress.models import StreakRecord
from progress.services import effective_current_streak
today = today or timezone.localdate()
first_day = today - timedelta(days=2 * WINDOW_DAYS - 1)
since = timezone.make_aware(datetime.combine(first_day, time.min))
days = {first_day + timedelta(days=i): _empty_day(first_day + timedelta(days=i))
for i in range(2 * WINDOW_DAYS)}
stamps = defaultdict(list)
# (روزِ پنجره، شناسه) برای شمارشِ یکتای درس و جزیره در هر پنجره.
lessons_by_day = defaultdict(set)
nodes_by_day = defaultdict(set)
def bucket(created_at):
day = timezone.localtime(created_at).date()
stamps[day].append(created_at)
return days.get(day)
for created_at, correct in UserAnswer.objects.filter(
user_id=user_id, created_at__gte=since,
).values_list('created_at', 'correct'):
d = bucket(created_at)
if d is not None:
d['questions'] += 1
d['correct'] += int(correct)
for created_at, lesson_id, xp in UserLessonCompletion.objects.filter(
user_id=user_id, created_at__gte=since,
).values_list('created_at', 'lesson_id', 'xp_earned'):
d = bucket(created_at)
if d is not None:
d['xp'] += xp
lessons_by_day[d['date']].add(lesson_id)
for created_at, node_id, xp in UserNodeCompletion.objects.filter(
user_id=user_id, created_at__gte=since,
).values_list('created_at', 'node_id', 'xp_earned'):
d = bucket(created_at)
if d is not None:
d['xp'] += xp
nodes_by_day[d['date']].add(node_id)
for created_at in Message.objects.filter(
conversation__user_id=user_id, role='user', created_at__gte=since,
).values_list('created_at', flat=True):
d = bucket(created_at)
if d is not None:
d['aiQuestions'] += 1
for day, d in days.items():
d['minutes'] = estimate_minutes(stamps[day])
d['lessons'] = len(lessons_by_day[day])
d['islands'] = len(nodes_by_day[day])
ordered = [days[k] for k in sorted(days)]
previous, current = ordered[:WINDOW_DAYS], ordered[WINDOW_DAYS:]
def ids(sets_by_day, window):
out = set()
for d in window:
out |= sets_by_day[d['date']]
return out
streak = StreakRecord.objects.filter(user_id=user_id).first()
return {
'today': today,
'days': current,
'previousDays': previous,
'thisWeek': _totals(current, ids(lessons_by_day, current), ids(nodes_by_day, current)),
'lastWeek': _totals(previous, ids(lessons_by_day, previous), ids(nodes_by_day, previous)),
'streak': {
'current': effective_current_streak(streak, today=today),
'longest': streak.longest_streak if streak else 0,
},
}

View File

@@ -65,3 +65,34 @@ class SelectCharacterRequestSerializer(serializers.Serializer):
class SelectCharacterResponseSerializer(serializers.Serializer):
characterId = serializers.CharField()
selected = serializers.BooleanField()
class WeeklyReportDaySerializer(serializers.Serializer):
date = serializers.DateField()
minutes = serializers.IntegerField(help_text='تخمینی؛ از فاصله‌ی رویدادها، نه زمانِ واقعیِ باز بودنِ اپ.')
questions = serializers.IntegerField()
correct = serializers.IntegerField()
xp = serializers.IntegerField()
lessons = serializers.IntegerField()
islands = serializers.IntegerField()
aiQuestions = serializers.IntegerField()
class WeeklyReportTotalsSerializer(serializers.Serializer):
minutes = serializers.IntegerField()
activeDays = serializers.IntegerField()
questions = serializers.IntegerField()
correct = serializers.IntegerField()
xp = serializers.IntegerField()
lessons = serializers.IntegerField(help_text='درس‌های یکتا؛ تکرارِ یک درس یک بار شمرده می‌شود.')
islands = serializers.IntegerField(help_text='جزیره‌های یکتا.')
aiQuestions = serializers.IntegerField()
class WeeklyReportResponseSerializer(serializers.Serializer):
today = serializers.DateField()
days = WeeklyReportDaySerializer(many=True, help_text='۷ روزِ آخر تا امروز، قدیمی به جدید.')
previousDays = WeeklyReportDaySerializer(many=True, help_text='۷ روزِ پیش از آن، قدیمی به جدید.')
thisWeek = WeeklyReportTotalsSerializer()
lastWeek = WeeklyReportTotalsSerializer()
streak = StreakSummarySerializer()

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)

View File

@@ -7,10 +7,12 @@ from .views import (
StreakCalendarView,
UserHeartsView,
UserSummaryView,
WeeklyReportView,
)
urlpatterns = [
path('user/summary', UserSummaryView.as_view(), name='user-summary'),
path('user/weekly-report', WeeklyReportView.as_view(), name='user-weekly-report'),
path('user/streak/calendar', StreakCalendarView.as_view(), name='streak-calendar'),
path('leaderboard', LeaderboardView.as_view(), name='leaderboard'),
path('user/hearts', UserHeartsView.as_view(), name='user-hearts'),

View File

@@ -32,6 +32,7 @@ from .serializers import (
StreakCalendarResponseSerializer,
UserHeartsResponseSerializer,
UserSummaryResponseSerializer,
WeeklyReportResponseSerializer,
)
@@ -100,6 +101,26 @@ class StreakCalendarView(APIView):
return Response(StreakCalendarResponseSerializer(data).data)
class WeeklyReportView(APIView):
"""
GET /api/user/weekly-report — گزارشِ ۷ روزِ اخیرِ خودِ کاربر در کنارِ ۷ روزِ قبلش.
فرانت آن را با عنوانِ «گزارشِ والدین» نشان می‌دهد؛ والد همین را در حسابِ خودِ
دانش‌آموز می‌بیند و حسابِ جدا ندارد. فقط دیتای خودِ درخواست‌دهنده را برمی‌گرداند.
محاسبه و این‌که `minutes` تخمینی است: `progress/report.py`.
"""
permission_classes = [IsAuthenticated]
throttle_scope = 'read'
throttle_classes = [GuestAwareReadThrottle]
@extend_schema(responses=WeeklyReportResponseSerializer, tags=['Progress & Gamification (progress)'])
def get(self, request):
from .report import weekly_report
return Response(WeeklyReportResponseSerializer(weekly_report(request.user.id)).data)
# کوتاه تا XPی تازه زود دیده شود، بلند تا هزار کاربرِ هم‌زمان هر بار جدول را
# از نو حساب نکنند.
LEADERBOARD_CACHE_TTL = 60