Files
MathApp/accounts/models.py

120 lines
6.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import hashlib
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)
name = models.CharField(max_length=100, blank=True)
avatar = models.CharField(max_length=100, blank=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']
def __str__(self):
return self.name or self.phone or f"user-{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}"
def hash_refresh_token(raw: str) -> str:
"""آنچه در Session.refresh_token ذخیره و با آن جست‌وجو می‌شود؛ خودِ توکن فقط دستِ کلاینت است."""
return hashlib.sha256(raw.encode()).hexdigest()
class Session(models.Model):
"""
یک ورود. توکنِ دسترسی ذخیره نمی‌شود (امضاشده و stateless است، accounts/authentication.py).
`refresh_token` هشِ SHA-256ِ توکنِ تمدید است، نه خودش (hash_refresh_token): هر کس دیتابیس،
یک بکاپ یا پنلِ ادمین را ببیند با آن نمی‌تواند به‌جای کاربر وارد شود. توکن ۶۴ نویسه‌ی تصادفی
است، پس هشِ سریعِ بی‌نمک کافی است.
"""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sessions')
refresh_token = models.CharField(max_length=200, unique=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}"
class LoginEvent(models.Model):
"""
سابقه‌ی ماندگارِ ورود و خروج، برای پرسش‌هایی مثلِ «این کاربر کی آمد، کی رفت».
چرا جدول و نه فقط لاگ: لاگِ stdout با ری‌استارت یا چرخشِ journald از بین می‌رود؛ این ردیف‌ها
در دیتابیس و بکاپ می‌مانند. `user_id` ارجاعِ سبک است (نه FK، مثلِ بقیه‌ی پروژه)، و ردیف با حذفِ
حساب نمی‌ماند مگر در KEPT_ON_ACCOUNT_DELETE. شماره‌ی کامل برای ادمین‌ها ذخیره می‌شود
(تصمیمِ مالک، ۲۰۲۶-۱۰-۰۸)؛ در سیاستِ حریمِ خصوصی باید بیاید.
"""
LOGIN_OK = 'login_ok'
LOGIN_FAILED = 'login_failed' # کدِ غلط یا منقضی
LOGIN_BLOCKED = 'login_blocked' # سقفِ تلاشِ شماره (429)
LOGOUT = 'logout'
ACCOUNT_DELETED = 'account_deleted'
EVENT_CHOICES = [
(LOGIN_OK, 'ورود موفق'), (LOGIN_FAILED, 'ورود ناموفق'), (LOGIN_BLOCKED, 'ورود مسدود'),
(LOGOUT, 'خروج'), (ACCOUNT_DELETED, 'حذف حساب'),
]
event = models.CharField(max_length=20, choices=EVENT_CHOICES, db_index=True)
user_id = models.IntegerField(null=True, blank=True, db_index=True)
phone = models.CharField(max_length=15, blank=True, db_index=True)
ip = models.GenericIPAddressField(null=True, blank=True)
user_agent = models.CharField(max_length=300, blank=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f"{self.event} user={self.user_id} {self.created_at:%Y-%m-%d %H:%M}"