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

@@ -51,16 +51,10 @@ class SetGradeRequestSerializer(serializers.Serializer):
gradeId = serializers.IntegerField()
def _jalali_year_now() -> int:
"""
سالِ جاریِ شمسی. تبدیلِ سن به سالِ تولد بدونِ این، سالِ میلادی می‌داد و
اعتبارسنجیِ birthYear (بازه‌ی ۱۳۰۰ تا ۱۵۰۰) ردش می‌کرد.
"""
from django.utils import timezone
today = timezone.localdate()
# فروردین ۱ برابرِ ۲۱ مارس است؛ پیش از آن هنوز سالِ شمسیِ قبلی است.
return today.year - 622 if (today.month, today.day) >= (3, 21) else today.year - 623
# همان بازه‌ای که پیش‌تر برای «سن» پذیرفته می‌شد. بزرگسال هم می‌آید (سینیور،
# ۲۰۲۶-۱۰-۰۱): کسی که فقط یک مبحثِ ریاضی را می‌خواهد یاد بگیرد.
MIN_AGE = 5
MAX_AGE = 99
class UpdateProfileRequestSerializer(serializers.Serializer):
@@ -81,27 +75,36 @@ class UpdateProfileRequestSerializer(serializers.Serializer):
نمی‌دهد تا صفحه‌ی پروفایلِ اپ گیر نکند؛ فقط بقیه‌ی نام ذخیره می‌شود.
"""
return clean_display_name(value)
# فقط سال (روشِ قدیمِ اپ). birthDate دقیق‌تر است و ترجیح دارد.
birthYear = serializers.IntegerField(required=False, allow_null=True,
min_value=1300, max_value=1500)
parentPhone = serializers.CharField(max_length=15, required=False, allow_blank=True)
# تاریخِ تولدِ شمسی «1385-04-12». سرور میلادی ذخیره می‌کند و birthYear را هم
# از آن پر می‌کند؛ تبدیل فقط در accounts/jalali.py است.
birthDate = serializers.CharField(required=False, allow_null=True, max_length=10)
bio = serializers.CharField(max_length=200, required=False, allow_blank=True)
# صفحه‌ی ثبت‌نامِ فرانت سن می‌پرسد، نه سالِ تولد. ذخیره‌سازی همچنان
# birthYear است چون کهنه نمی‌شود؛ این فقط ورودی را ترجمه می‌کند تا فرانت
# مجبور نباشد خودش تبدیل کند و دو طرف حسابِ متفاوت درنیاورند.
age = serializers.IntegerField(required=False, allow_null=True,
min_value=5, max_value=99, write_only=True)
def validate_birthDate(self, value):
if value in (None, ''):
return None
from django.utils import timezone
from . import jalali
try:
born = jalali.parse(value)
except ValueError as exc:
raise serializers.ValidationError(str(exc))
today = timezone.localdate()
age = today.year - born.year - ((today.month, today.day) < (born.month, born.day))
if not MIN_AGE <= age <= MAX_AGE:
raise serializers.ValidationError(f'سن باید بینِ {MIN_AGE} و {MAX_AGE} سال باشد.')
return born
def validate(self, attrs):
if not attrs:
raise serializers.ValidationError('حداقل یکی از فیلدها باید ارسال شود.')
age = attrs.pop('age', None)
if age is not None:
if attrs.get('birthYear') is not None:
raise serializers.ValidationError(
'یکی از age یا birthYear را بفرستید، نه هر دو را.',
)
attrs['birthYear'] = _jalali_year_now() - age
if 'birthDate' in attrs and 'birthYear' in attrs:
raise serializers.ValidationError('یکی از birthDate یا birthYear را بفرستید، نه هر دو را.')
return attrs
@@ -156,6 +159,8 @@ class RequestOTPResponseSerializer(serializers.Serializer):
class ProfileResponseSerializer(serializers.Serializer):
"""پاسخ GET /api/user/profile — فقط داده‌ی لازم برای تب Profile اپ موبایل."""
# شناسه‌ی کاربر همان شماره‌ی ثبتِ اوست (کاربرِ چندم)؛ اپ آن را زیرِ نام نشان می‌دهد
# تا در پشتیبانی بشود حساب را بی‌شماره‌ی تلفن پیدا کرد.
userId = serializers.CharField()
phone = serializers.CharField(allow_null=True)
name = serializers.CharField()
@@ -163,7 +168,7 @@ class ProfileResponseSerializer(serializers.Serializer):
isGuest = serializers.BooleanField()
gradeId = serializers.IntegerField(allow_null=True)
birthYear = serializers.IntegerField(allow_null=True)
parentPhone = serializers.CharField()
birthDate = serializers.CharField(allow_null=True, help_text='شمسی، «1385-04-12»؛ null اگر فقط سال داده شده.')
bio = serializers.CharField()