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