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

@@ -5,7 +5,8 @@ from .models import Plan, Subscription
@admin.register(Plan)
class PlanAdmin(admin.ModelAdmin):
list_display = ('id', 'title', 'price', 'currency', 'discount_percent', 'popular')
list_display = ('id', 'title', 'price', 'currency', 'duration_days', 'ai_daily_quota', 'discount_percent', 'popular')
list_editable = ('ai_daily_quota',)
@admin.register(Subscription)

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.17 on 2026-10-01 22:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('subscription', '0005_seed_yearly_plan'),
]
operations = [
migrations.AddField(
model_name='plan',
name='ai_daily_quota',
field=models.PositiveSmallIntegerField(default=15, help_text='سقفِ سوالِ روزانه از دستیارِ هوش مصنوعی برای مشترکانِ این پلن.'),
),
]

View File

@@ -26,6 +26,11 @@ class Plan(models.Model):
# برچسبِ بالای کارت: «محبوب‌ترین»، «بهترین ارزش». `popular` فقط بولین است
# و یک متن نمی‌دهد؛ این جایگزینش نیست، کنارش است.
badge = models.CharField(max_length=30, blank=True)
# چند سوال در روز از دستیار. هر سوال حدودِ ۰٫۲ سنتِ دلار (~۶۰۰ تومان) هزینه‌ی API
# دارد (docs/ai-cost-and-guardrails.md)، پس این عدد مستقیم سودِ پلن را تعیین می‌کند.
# کاربرِ بدونِ اشتراک سقفِ AI_DAILY_QUOTA_FREE (settings) را دارد.
ai_daily_quota = models.PositiveSmallIntegerField(
default=15, help_text='سقفِ سوالِ روزانه از دستیارِ هوش مصنوعی برای مشترکانِ این پلن.')
def __str__(self):
return self.title

View File

@@ -19,6 +19,26 @@ def has_unlimited_hearts(user_id: int) -> bool:
).exists()
# اشتراکِ بی‌پلن (هدیه/آفر با grant_free_subscription) همان سقفِ پیش‌فرضِ پلن را می‌گیرد.
DEFAULT_SUBSCRIBER_AI_QUOTA = 15
def ai_daily_quota(user_id: int) -> int:
"""
سقفِ سوالِ روزانه از دستیار برای این کاربر: بیشترینِ سقفِ پلن‌های اشتراکِ فعالش،
یا AI_DAILY_QUOTA_FREE اگر اشتراکِ فعال ندارد. تا ۲۰۲۶-۱۰-۰۱ همه ۱۵ سوال داشتند،
با اشتراک یا بی‌اشتراک — یعنی دستیار، گران‌ترین بخشِ اپ، رایگان بود.
"""
from django.conf import settings
now = timezone.now()
active = Subscription.objects.filter(user_id=user_id, status='active').filter(
models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=now))
quotas = [s.plan.ai_daily_quota if s.plan else DEFAULT_SUBSCRIBER_AI_QUOTA
for s in active.select_related('plan')]
return max(quotas) if quotas else settings.AI_DAILY_QUOTA_FREE
def activate_subscription(subscription_id: int, plan_id: int | None = None):
"""
بعد از تاییدِ پرداخت صدا زده می‌شود (کال‌بکِ زرین‌پال، تاییدِ بازار).

View File

@@ -23,7 +23,7 @@ from .services import has_unlimited_hearts
class GuestAwarePurchaseThrottle(ScopedRateThrottle):
"""همون الگوی GuestAwareAIThrottle/GuestAwareFamilyThrottle/etc: چرخشِ
"""همون الگوی GuestAwareAIThrottle/GuestAwareDiscountThrottle/etc: چرخشِ
حساب مهمان نباید bucket تازه‌ی purchase بده — این endpoint حساسِ مالیه
(Subscription می‌سازه/آپدیت می‌کنه، create_order رو صدا می‌زنه). برای
مهمان‌ها کلیدِ cache بر اساسِ IP هست، نه pk."""
@@ -84,7 +84,7 @@ class PurchaseView(APIView):
# کلید رو می‌گیره، نه حجمِ درخواست‌های متفاوت. کاربرهای مهمان با هر
# deviceId یک pk تازه می‌گیرن، پس کلیدِ per-user ساده قابل‌دورزدنه؛
# GuestAwarePurchaseThrottle برای مهمان‌ها بر اساسِ IP کلید می‌سازه
# (مثلِ GuestAwareAIThrottle/FamilyThrottle/DiscountThrottle) و برای
# (مثلِ GuestAwareAIThrottle/DiscountThrottle) و برای
# کاربرهای غیرمهمان رفتارِ per-user را حفظ می‌کنه.
throttle_scope = 'purchase'
throttle_classes = [GuestAwarePurchaseThrottle]
@@ -171,6 +171,7 @@ class PurchaseView(APIView):
subscription_id=subscription.id,
idempotency_key=idempotency_key,
plan_id=plan.id,
plan_title=plan.title,
)
except Exception:
# Guard by pk+status+plan_id+order_id+version so this cleanup only