- 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>
90 lines
4.6 KiB
Python
90 lines
4.6 KiB
Python
from django.db import models
|
||
|
||
|
||
class User(models.Model):
|
||
"""
|
||
کاربر اصلی سیستم (مهمان یا ثبتنامشده با شماره).
|
||
توجه: این مدل، AUTH_USER_MODEL جنگو نیست؛ یک مدل بیزنسی مستقله که
|
||
با TadrisinoTokenAuthentication سفارشی احراز هویت میشه.
|
||
|
||
grade_id فقط یک ارجاع سبک (بدون FK واقعی) به content.Grade هست؛
|
||
این اپ عمداً هیچوقت FK واقعی به مدل اپ دیگه نمیزنه.
|
||
"""
|
||
phone = models.CharField(max_length=15, unique=True, null=True, blank=True, db_index=True)
|
||
device_id = models.CharField(max_length=64, null=True, blank=True, db_index=True)
|
||
name = models.CharField(max_length=100, blank=True)
|
||
avatar = models.CharField(max_length=100, blank=True)
|
||
is_guest = models.BooleanField(default=True)
|
||
grade_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Grade
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
# آخرین زمانِ فعالیتِ کاربر (لاگین موفق، رفرش توکن موفق، یا سشنِ
|
||
# مهمانِ واقعی). فقط برای هدفگیریِ آیندهی کمپینهای پیامی نگهداری
|
||
# میشه؛ در حال حاضر هیچ منطقِ دیگهای به این فیلد وابسته نیست.
|
||
last_activity_at = models.DateTimeField(null=True, blank=True)
|
||
# access tokenهای صادرشده پیش از این لحظه رد میشوند (خروج از حساب). توکنِ
|
||
# access فقط user_id امضاشده است و به Session وصل نیست؛ بدونِ این، خروج
|
||
# فقط refresh را باطل میکرد و توکنِ فعلی تا یک ساعت کار میکرد.
|
||
tokens_valid_after = models.DateTimeField(null=True, blank=True)
|
||
|
||
class Meta:
|
||
ordering = ['-created_at']
|
||
constraints = [
|
||
# جلوگیری از ساخت دو کاربر مهمان همزمان با یک device_id یکسان.
|
||
models.UniqueConstraint(
|
||
fields=['device_id'],
|
||
condition=models.Q(is_guest=True) & ~models.Q(device_id=None) & ~models.Q(device_id=''),
|
||
name='uniq_guest_device_id',
|
||
),
|
||
]
|
||
|
||
def __str__(self):
|
||
return self.name or self.phone or f"guest-{self.pk}"
|
||
|
||
@property
|
||
def is_authenticated(self):
|
||
# این مدل AUTH_USER_MODEL نیست؛ این property فقط برای سازگاری با
|
||
# rest_framework.permissions.IsAuthenticated لازمه.
|
||
return True
|
||
|
||
|
||
class Profile(models.Model):
|
||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
|
||
# سالِ تولدِ شمسی. از ۲۰۲۶-۱۰-۰۱ از birth_date پر میشود؛ برای کاربرهای
|
||
# قدیمی که فقط سال دادهاند تنها منبع است.
|
||
birth_year = models.PositiveSmallIntegerField(null=True, blank=True)
|
||
# تاریخِ تولدِ کامل (میلادی ذخیره میشود، در API شمسی است؛ accounts/jalali.py).
|
||
birth_date = models.DateField(null=True, blank=True)
|
||
bio = models.CharField(max_length=200, blank=True)
|
||
|
||
def __str__(self):
|
||
return f"profile-{self.user_id}"
|
||
|
||
|
||
class Session(models.Model):
|
||
"""
|
||
نگهداری refresh_token هر ورود. توکن دسترسی (access token) در دیتابیس
|
||
ذخیره نمیشه؛ بهصورت stateless امضا (signed) میشه — دقیقاً طبق فیلدهای
|
||
همین مدل که فقط refresh_token داره، نه access token.
|
||
"""
|
||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sessions')
|
||
refresh_token = models.CharField(max_length=200, unique=True)
|
||
device_id = models.CharField(max_length=64, blank=True)
|
||
expires_at = models.DateTimeField()
|
||
revoked = models.BooleanField(default=False)
|
||
|
||
def __str__(self):
|
||
return f"session-{self.pk}-user{self.user_id}"
|
||
|
||
|
||
class OTPCode(models.Model):
|
||
phone = models.CharField(max_length=15, db_index=True)
|
||
code = models.CharField(max_length=6)
|
||
expires_at = models.DateTimeField()
|
||
is_used = models.BooleanField(default=False)
|
||
# تلاشهای غلط روی همین کد. در دیتابیس شمرده میشود، نه در کشِ LocMem که
|
||
# برای هر worker جداست؛ بعد از OTP_MAX_FAILED_ATTEMPTS کد باطل میشود.
|
||
failed_attempts = models.PositiveSmallIntegerField(default=0)
|
||
|
||
def __str__(self):
|
||
return f"otp-{self.phone}"
|