Files
shopship/apps/account/models.py
T
2026-08-24 15:54:08 +03:30

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