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

@@ -16,6 +16,7 @@ from . import bazaar, zarinpal
from .models import Order
from .permissions import HasInternalServiceKey
from .serializers import (
InvoiceListResponseSerializer,
BazaarVerifyRequestSerializer,
BazaarVerifyResponseSerializer,
CreateOrderRequestSerializer,
@@ -43,7 +44,7 @@ def _redirect_to_configured_url(url: str) -> HttpResponseRedirect:
class GuestAwareBazaarThrottle(ScopedRateThrottle):
"""همون الگوی GuestAwareAIThrottle/GuestAwareFamilyThrottle/etc: چرخشِ
"""همون الگوی GuestAwareAIThrottle/GuestAwareDiscountThrottle/etc: چرخشِ
حساب مهمان (هر deviceId یه pk تازه) نباید bucket تازه‌ی bazaar_verify
بده، چون هر درخواستِ پذیرفته‌شده یه فراخوانیِ سرور-به-سرور به بازاره.
برای مهمان‌ها کلیدِ cache بر اساسِ IP هست، نه pk."""
@@ -365,3 +366,53 @@ class OrderDetailView(APIView):
],
}
return Response(OrderDetailResponseSerializer(data).data)
INVOICE_LIMIT = 50
def invoice_payload(order) -> dict:
"""یک سفارش به شکلِ فاکتور. کدِ تخفیف و کدِ پیگیری از جدول‌های خودشان خوانده می‌شوند."""
from discount.models import Redemption
redemption = Redemption.objects.filter(order_id=order.id).select_related('discount_code').first()
paid = next((t for t in order.transactions.all() if t.status == 'paid'), None)
# کدِ تخفیفِ ۱۰۰٪ هم یک Transactionِ «paid» می‌سازد، با refی ساختگی (discount-…)؛
# آن کدِ پیگیریِ بانکی نیست و به کاربر نشان داده نمی‌شود.
ref = paid.gateway_ref_id if paid and paid.gateway != 'discount' else ''
return {
'orderId': str(order.id),
'invoiceNumber': f'TD-{order.id:06d}',
'status': order.status,
'provider': order.provider,
'planTitle': order.plan_title,
'listPrice': order.list_price,
'discountAmount': (order.list_price - order.amount) if order.list_price is not None else None,
'discountCode': redemption.discount_code.code if redemption else '',
'amount': order.amount,
'currency': order.currency,
'createdAt': order.created_at,
'paidAt': paid.paid_at if paid else None,
'refId': ref,
}
class InvoiceListView(APIView):
"""
GET /api/payment/invoices — پرداخت‌ها و فاکتورهای خودِ کاربر، تازه‌ترین اول.
همه‌ی سفارش‌ها برمی‌گردند، نه فقط پرداخت‌شده‌ها، تا کسی که پولش کم شده ولی
اشتراکش فعال نشده سفارشِ «در انتظار» یا «ناموفق» خودش را ببیند و کدش را به
پشتیبانی بدهد. این‌جا از زرین‌پال بازسنجی نمی‌شود (برای ۵۰ سفارش ۵۰ فراخوانی
می‌شد)؛ GET /api/payment/orders/{id} و وضعیتِ اشتراک این کار را می‌کنند.
"""
permission_classes = [IsAuthenticated]
throttle_scope = 'read'
throttle_classes = [ScopedRateThrottle]
@extend_schema(responses=InvoiceListResponseSerializer, tags=['Payment (payment)'])
def get(self, request):
orders = (Order.objects.filter(user_id=request.user.id)
.prefetch_related('transactions').order_by('-created_at', '-id')[:INVOICE_LIMIT])
return Response(InvoiceListResponseSerializer({'invoices': [invoice_payload(o) for o in orders]}).data)