Files

185 lines
7.1 KiB
Python
Raw Permalink Normal View History

2026-08-24 15:54:08 +03:30
import random
import string
from datetime import timedelta
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
TRIAL_DAYS = 7
_REFERRAL_CODE_ALPHABET = string.ascii_uppercase + string.digits
# -------------------- Manager --------------------
class UserManager(BaseUserManager):
def create_user(self, phone, password=None, **extra_fields):
if not phone:
raise ValueError(_("شماره موبایل الزامی است"))
phone = self.normalize_phone(phone)
user = self.model(phone=phone, **extra_fields)
if password:
user.set_password(password)
else:
user.set_unusable_password()
user.save(using=self._db)
return user
def create_superuser(self, phone, password, **extra_fields):
extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", True)
extra_fields.setdefault("is_active", True)
extra_fields.setdefault("state", User.State.ACTIVE)
if not password:
raise ValueError(_("سوپریوزر باید رمز عبور داشته باشد"))
return self.create_user(phone, password, **extra_fields)
@staticmethod
def normalize_phone(phone: str) -> str:
phone = phone.strip().replace(" ", "").replace("-", "")
if phone.startswith("+98"):
phone = "0" + phone[3:]
elif phone.startswith("98") and len(phone) == 12:
phone = "0" + phone[2:]
return phone
# -------------------- Validators --------------------
def phone_validator(phone: str):
"""شماره موبایل: فقط عدد، شروع با 09، طول ۱۱ رقم"""
if not phone.isdigit():
raise ValidationError(_("شماره موبایل باید فقط شامل اعداد باشد"))
if not phone.startswith("09"):
raise ValidationError(_("شماره موبایل باید با 09 شروع شود"))
if len(phone) != 11:
raise ValidationError(_("شماره موبایل باید ۱۱ رقم باشد"))
return phone
# -------------------- User Model --------------------
class User(AbstractBaseUser, PermissionsMixin):
class State(models.TextChoices):
INACTIVE = "inactive", _("غیرفعال")
ACTIVE = "active", _("فعال")
BLOCKED = "blocked", _("مسدود شده")
phone = models.CharField(
max_length=11,
unique=True,
validators=[phone_validator],
verbose_name=_("شماره موبایل"),
)
name = models.CharField(max_length=100, blank=True, verbose_name=_("نام"))
state = models.CharField(
max_length=10,
choices=State.choices,
default=State.INACTIVE,
verbose_name=_("وضعیت کاربر"),
)
# OTP — کد به‌صورت هش‌شده ذخیره می‌شود، هرگز متن ساده
otp_code = models.CharField(max_length=128, blank=True, null=True, verbose_name=_("کد OTP (هش‌شده)"))
otp_expiry = models.DateTimeField(blank=True, null=True, verbose_name=_("انقضای OTP"))
is_active = models.BooleanField(default=True, verbose_name=_("فعال"))
is_staff = models.BooleanField(default=False, verbose_name=_("کارمند"))
created_at = models.DateTimeField(default=timezone.now, verbose_name=_("تاریخ ایجاد"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("تاریخ بروزرسانی"))
# -------------------- جم / اشتراک / معرفی --------------------
gem_balance = models.PositiveIntegerField(default=0, verbose_name=_("موجودی جم"))
trial_expires_at = models.DateTimeField(
default=None, null=True, blank=True, verbose_name=_("پایان دوره‌ی رایگان")
)
referral_code = models.CharField(
max_length=8, unique=True, blank=True, verbose_name=_("کد معرف")
)
referred_by = models.ForeignKey(
"self",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="referrals",
verbose_name=_("معرف"),
)
# جلوگیری از دوبار پاداش‌دادن وقتی همان کاربر چندبار verify می‌شود —
# پاداشِ معرف فقط یک‌بار، دقیقا سر اولین verify موفق کاربر جدید.
referral_bonus_granted = models.BooleanField(default=False, verbose_name=_("پاداش معرفی داده‌شد"))
objects = UserManager()
USERNAME_FIELD = "phone"
REQUIRED_FIELDS = []
class Meta:
verbose_name = _("کاربر")
verbose_name_plural = _("کاربران")
def __str__(self):
return f"{self.phone} - {self.name}".strip(" -")
def save(self, *args, **kwargs):
if not self.referral_code:
self.referral_code = self._generate_referral_code()
if self.trial_expires_at is None and self._state.adding:
self.trial_expires_at = timezone.now() + timedelta(days=TRIAL_DAYS)
super().save(*args, **kwargs)
@staticmethod
def _generate_referral_code() -> str:
for _attempt in range(20):
code = "".join(random.choices(_REFERRAL_CODE_ALPHABET, k=6))
if not User.objects.filter(referral_code=code).exists():
return code
# عملا در این مقیاس هرگز رخ نمی‌دهد؛ فقط یک fallback امن
return "".join(random.choices(_REFERRAL_CODE_ALPHABET, k=8))
@property
def is_in_trial(self) -> bool:
return bool(self.trial_expires_at and timezone.now() < self.trial_expires_at)
def activate(self):
self.state = self.State.ACTIVE
self.is_active = True
self.otp_code = None
self.otp_expiry = None
self.save(update_fields=["state", "is_active", "otp_code", "otp_expiry"])
def block(self):
self.state = self.State.BLOCKED
self.is_active = False
self.save(update_fields=["state", "is_active"])
class TermsAndConditions(models.Model):
"""متن قوانین و مقررات — تک‌ردیفی (singleton)، از پنل ادمین ویرایش
می‌شود و صفحه‌ی ورود قبل از لاگین آن را می‌خواند (بدون نیاز به توکن)."""
content = models.TextField(
blank=True, verbose_name=_("متن قوانین و مقررات"), help_text=_("این متن در دیالوگ «قوانین و مقررات» صفحه‌ی ورود نشان داده می‌شود.")
)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("قوانین و مقررات")
verbose_name_plural = _("قوانین و مقررات")
def __str__(self):
return "قوانین و مقررات"
def save(self, *args, **kwargs):
self.pk = 1
super().save(*args, **kwargs)
@classmethod
def get_solo(cls) -> "TermsAndConditions":
obj, _created = cls.objects.get_or_create(pk=1)
return obj