209 lines
7.6 KiB
Python
209 lines
7.6 KiB
Python
import secrets
|
|
import string
|
|
from datetime import timedelta
|
|
|
|
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
|
|
from django.db import models
|
|
from django.utils import timezone
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
from .validators import normalize_iran_mobile, validate_iran_mobile
|
|
|
|
|
|
_REFERRAL_ALPHABET = string.ascii_uppercase + string.digits
|
|
|
|
|
|
def generate_referral_code(length: int = 6) -> str:
|
|
return "".join(secrets.choice(_REFERRAL_ALPHABET) for _ in range(length))
|
|
|
|
|
|
class Role(models.TextChoices):
|
|
STUDENT = "student", _("Student")
|
|
TEACHER = "teacher", _("Teacher")
|
|
ADMIN = "admin", _("Admin")
|
|
|
|
|
|
class UserManager(BaseUserManager):
|
|
use_in_migrations = True
|
|
|
|
def _create_user(self, phone_number, password=None, **extra_fields):
|
|
if not phone_number:
|
|
raise ValueError("Phone number is required")
|
|
phone_number = normalize_iran_mobile(phone_number)
|
|
validate_iran_mobile(phone_number)
|
|
user = self.model(phone_number=phone_number, **extra_fields)
|
|
if password:
|
|
user.set_password(password)
|
|
else:
|
|
user.set_unusable_password()
|
|
user.save(using=self._db)
|
|
return user
|
|
|
|
def create_user(self, phone_number, password=None, **extra_fields):
|
|
extra_fields.setdefault("is_staff", False)
|
|
extra_fields.setdefault("is_superuser", False)
|
|
return self._create_user(phone_number, password, **extra_fields)
|
|
|
|
def create_superuser(self, phone_number, password=None, **extra_fields):
|
|
extra_fields.setdefault("is_staff", True)
|
|
extra_fields.setdefault("is_superuser", True)
|
|
extra_fields.setdefault("role", Role.ADMIN)
|
|
extra_fields.setdefault("full_name", "Admin")
|
|
if extra_fields.get("is_staff") is not True:
|
|
raise ValueError("Superuser must have is_staff=True.")
|
|
if extra_fields.get("is_superuser") is not True:
|
|
raise ValueError("Superuser must have is_superuser=True.")
|
|
return self._create_user(phone_number, password, **extra_fields)
|
|
|
|
|
|
class User(AbstractBaseUser, PermissionsMixin):
|
|
phone_number = models.CharField(_("phone number"), max_length=11, unique=True)
|
|
full_name = models.CharField(_("full name"), max_length=150)
|
|
role = models.CharField(_("role"), max_length=16, choices=Role.choices, default=Role.STUDENT)
|
|
email = models.EmailField(_("email"), blank=True)
|
|
language = models.CharField(
|
|
_("language"),
|
|
max_length=5,
|
|
choices=[("en", "English"), ("fa", "Persian")],
|
|
default="fa",
|
|
)
|
|
|
|
is_active = models.BooleanField(_("active"), default=True)
|
|
is_staff = models.BooleanField(_("staff status"), default=False)
|
|
date_joined = models.DateTimeField(_("date joined"), default=timezone.now)
|
|
last_active_at = models.DateTimeField(
|
|
_("last active at"), null=True, blank=True, db_index=True,
|
|
help_text=_("Updated on each authenticated API request (debounced ~5 minutes)."),
|
|
)
|
|
referral_code = models.CharField(
|
|
_("referral code"), max_length=12, unique=True, blank=True, db_index=True,
|
|
help_text=_("Auto-generated 6-char code that other users can apply at checkout."),
|
|
)
|
|
|
|
objects = UserManager()
|
|
|
|
USERNAME_FIELD = "phone_number"
|
|
REQUIRED_FIELDS = ["full_name"]
|
|
|
|
class Meta:
|
|
verbose_name = _("user")
|
|
verbose_name_plural = _("users")
|
|
indexes = [models.Index(fields=["role"])]
|
|
|
|
def __str__(self):
|
|
return f"{self.full_name} ({self.phone_number})"
|
|
|
|
def clean(self):
|
|
super().clean()
|
|
self.phone_number = normalize_iran_mobile(self.phone_number)
|
|
validate_iran_mobile(self.phone_number)
|
|
|
|
def save(self, *args, **kwargs):
|
|
if not self.referral_code:
|
|
for _ in range(10):
|
|
candidate = generate_referral_code()
|
|
if not type(self).objects.filter(referral_code=candidate).exists():
|
|
self.referral_code = candidate
|
|
break
|
|
else:
|
|
# extremely unlikely; expand the alphabet/length if it ever happens
|
|
raise RuntimeError("Could not allocate a unique referral_code after 10 attempts")
|
|
super().save(*args, **kwargs)
|
|
|
|
def get_short_name(self):
|
|
return (self.full_name or "").split(" ", 1)[0] or self.phone_number
|
|
|
|
|
|
class TeacherProfile(models.Model):
|
|
user = models.OneToOneField(
|
|
"accounts.User",
|
|
on_delete=models.CASCADE,
|
|
related_name="teacher_profile",
|
|
primary_key=True,
|
|
)
|
|
bio = models.TextField(_("bio"), blank=True)
|
|
headline = models.CharField(
|
|
_("headline"), max_length=200, blank=True,
|
|
help_text=_("Short professional headline shown on instructor cards (e.g. 'Senior Product Designer · 10y experience')."),
|
|
)
|
|
avatar = models.ImageField(_("avatar"), upload_to="instructors/avatars/", blank=True, null=True)
|
|
years_experience = models.PositiveSmallIntegerField(_("years of experience"), null=True, blank=True)
|
|
is_verified = models.BooleanField(_("verified"), default=False)
|
|
is_featured = models.BooleanField(
|
|
_("featured"), default=False, db_index=True,
|
|
help_text=_("If true, the instructor appears in the public 'Featured instructors' strip."),
|
|
)
|
|
is_approved = models.BooleanField(_("approved"), default=False)
|
|
approved_at = models.DateTimeField(_("approved at"), null=True, blank=True)
|
|
approved_by = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.SET_NULL,
|
|
related_name="approved_teacher_profiles",
|
|
null=True,
|
|
blank=True,
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = _("teacher profile")
|
|
verbose_name_plural = _("teacher profiles")
|
|
|
|
def __str__(self):
|
|
return f"TeacherProfile<{self.user_id}>"
|
|
|
|
def approve(self, by_user):
|
|
self.is_approved = True
|
|
self.approved_at = timezone.now()
|
|
self.approved_by = by_user
|
|
self.save(update_fields=["is_approved", "approved_at", "approved_by"])
|
|
|
|
|
|
def _generate_otp_code(length: int = 4) -> str:
|
|
upper = 10 ** length
|
|
return f"{secrets.randbelow(upper):0{length}d}"
|
|
|
|
|
|
class OTPCode(models.Model):
|
|
MAX_ATTEMPTS = 5
|
|
|
|
phone_number = models.CharField(max_length=11, db_index=True)
|
|
code = models.CharField(max_length=6)
|
|
created_at = models.DateTimeField(default=timezone.now)
|
|
expires_at = models.DateTimeField()
|
|
attempts = models.PositiveIntegerField(default=0)
|
|
is_used = models.BooleanField(default=False)
|
|
consumed_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
class Meta:
|
|
indexes = [
|
|
models.Index(fields=["phone_number", "is_used"]),
|
|
models.Index(fields=["expires_at"]),
|
|
]
|
|
ordering = ["-created_at"]
|
|
|
|
def __str__(self):
|
|
return f"OTP<{self.phone_number} @ {self.created_at:%Y-%m-%d %H:%M:%S}>"
|
|
|
|
@classmethod
|
|
def issue(cls, phone_number: str, ttl_seconds: int = 120, length: int = 4) -> "OTPCode":
|
|
cls.objects.filter(phone_number=phone_number, is_used=False).update(
|
|
is_used=True, consumed_at=timezone.now()
|
|
)
|
|
return cls.objects.create(
|
|
phone_number=phone_number,
|
|
code=_generate_otp_code(length=length),
|
|
expires_at=timezone.now() + timedelta(seconds=ttl_seconds),
|
|
)
|
|
|
|
@property
|
|
def is_expired(self) -> bool:
|
|
return timezone.now() >= self.expires_at
|
|
|
|
@property
|
|
def is_valid(self) -> bool:
|
|
return not self.is_used and not self.is_expired and self.attempts < self.MAX_ATTEMPTS
|
|
|
|
def mark_consumed(self):
|
|
self.is_used = True
|
|
self.consumed_at = timezone.now()
|
|
self.save(update_fields=["is_used", "consumed_at"])
|