Ilo/apps/accounts/models.py
2026-05-02 20:01:30 +03:30

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"])