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:
@@ -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)
|
||||
|
||||
18
subscription/migrations/0006_plan_ai_daily_quota.py
Normal file
18
subscription/migrations/0006_plan_ai_daily_quota.py
Normal 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='سقفِ سوالِ روزانه از دستیارِ هوش مصنوعی برای مشترکانِ این پلن.'),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
بعد از تاییدِ پرداخت صدا زده میشود (کالبکِ زرینپال، تاییدِ بازار).
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user