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