185 lines
7.1 KiB
Python
185 lines
7.1 KiB
Python
|
|
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
|