import random import base64 import hashlib from cryptography.fernet import Fernet from django.conf import settings from django.contrib.auth.models import AbstractUser from django.db import models from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ from django.utils import timezone from phonenumber_field.modelfields import PhoneNumberField from utils.validators import validate_possible_number from apps.account.manager import UserManager class User(AbstractUser): class DeviceOs(models.TextChoices): android = 'android', _('Android') apple = 'apple', _('Apple iOS') web = 'web', _('Web') class UserType(models.TextChoices): CLIENT = 'client', _('Client / User') STUDENT = 'student', _('Student / Member') PROFESSOR = 'professor', _('Professor / Scholar') CONSULTANT = 'consultant', _('Consultant') ADMIN = 'admin', _('Admin') SUPER_ADMIN = 'super_admin', _('Super Admin') REGIONAL_ADMIN = 'regional_admin', _('Regional Admin') INSTITUTION_ADMIN = 'institution_admin', _('Institution Admin') EDITOR = 'editor', _('Editor') VIEWER = 'viewer', _('Viewer') class GenderChoices(models.TextChoices): MALE = 'male', _('Male') FEMALE = 'female', _('Female') OTHER = 'other', _('Other') first_name = models.CharField( max_length=150, verbose_name=_("First Name"), null=True, blank=True ) last_name = models.CharField( max_length=150, verbose_name=_("Last Name"), null=True, blank=True ) username = models.CharField( unique=True, null=True, blank=True, max_length=150, verbose_name=_("Username"), error_messages={'unique': _("A user with that username already exists.")} ) email = models.EmailField( unique=True, verbose_name=_("Email Address"), help_text=_("User primary email address."), null=True, blank=True, error_messages={'unique': _("A user with that email already exists.")} ) fullname = models.CharField( max_length=255, verbose_name=_("Full Name"), help_text=_("Full name of the user."), null=True, blank=True ) birthdate = models.DateField(verbose_name=_('Birthdate'), null=True, blank=True) avatar = models.ImageField( max_length=512, null=True, blank=True, upload_to='users/avatars/%Y/%m/', verbose_name=_('Avatar') ) phone_number = PhoneNumberField( validators=[validate_possible_number], null=True, blank=True, verbose_name=_('Phone Number'), help_text=_("e.g., +1 555 1234567") ) language = models.CharField( max_length=10, default='en', choices=settings.LANGUAGES, null=True, blank=True, verbose_name=_('Language') ) gender = models.CharField( max_length=20, choices=GenderChoices.choices, null=True, blank=True, verbose_name=_('Gender') ) user_type = models.CharField( max_length=20, choices=UserType.choices, default=UserType.CLIENT, verbose_name=_("User Type") ) date_joined = models.DateTimeField( auto_now_add=True, verbose_name=_("Date Joined") ) city = models.CharField(verbose_name=_('City'), max_length=255, null=True, blank=True) country = models.CharField(max_length=255, verbose_name=_('Country'), null=True, blank=True) title = models.CharField(max_length=255, null=True, blank=True, verbose_name=_('Title / Honorific')) rank = models.CharField(max_length=255, null=True, blank=True, verbose_name=_('Academic / Clerical Rank')) institution_name = models.CharField(max_length=255, null=True, blank=True, verbose_name=_('Affiliated Institution')) website = models.CharField(max_length=512, null=True, blank=True, verbose_name=_('Personal Website')) address = models.TextField(null=True, blank=True, verbose_name=_('Postal Address')) device_id = models.CharField(verbose_name=_('Device ID'), max_length=255, null=True, blank=True) device_os = models.CharField(choices=DeviceOs.choices, null=True, blank=True, max_length=16, verbose_name=_('Device OS')) user_agent = models.TextField(verbose_name=_('User Agent'), null=True, blank=True) client_ip = models.CharField(max_length=64, verbose_name=_('Client IP'), null=True, blank=True) fcm = models.CharField(max_length=512, null=True, blank=True, verbose_name=_('FCM Token')) slug = models.SlugField(max_length=255, unique=True, null=True, blank=True, verbose_name=_('Slug')) is_staff = models.BooleanField(default=False, verbose_name=_('Is Staff')) is_active = models.BooleanField( default=True, verbose_name=_("Active"), help_text=_("Designates whether this user should be treated as active.") ) deleted_at = models.DateTimeField(null=True, blank=True, verbose_name=_('Deleted At')) info = models.TextField(verbose_name=_("Bio / Info"), null=True, blank=True) skill = models.CharField(max_length=512, null=True, blank=True, verbose_name=_('Skill / Role')) languages = models.JSONField(default=list, blank=True, verbose_name=_('Languages')) skills = models.JSONField(default=list, blank=True, verbose_name=_('Skills')) password_enc = models.CharField( max_length=512, blank=True, default='', verbose_name=_("Encrypted Password"), help_text=_("Encrypted copy of password for displaying in the admin panel if required.") ) role = models.ForeignKey( 'account.Role', on_delete=models.SET_NULL, null=True, blank=True, related_name='users', verbose_name=_("Assigned Role") ) custom_permissions = models.JSONField( default=dict, blank=True, verbose_name=_("Custom Permission Overrides"), help_text=_("Dictionary of permission overrides: {permission_name: True/False}") ) managed_regions = models.ManyToManyField( 'profiles.Region', blank=True, related_name='regional_admins', verbose_name=_("Managed Regions"), help_text=_("Regions assigned to this regional administrator or staff member. Administrative actions and regional permissions apply only to these regions.") ) regional_permissions = models.JSONField( default=list, blank=True, verbose_name=_("Regional Permission Overrides"), help_text=_("List of permission names that are restricted to the user's managed regions (e.g. ['can_manage_institutions', 'can_manage_tickets']).") ) objects = UserManager() EMAIL_FIELD = "email" USERNAME_FIELD = "email" REQUIRED_FIELDS = [] def __str__(self): return self.email or self.fullname or self.username or f"User #{self.id}" def soft_delete(self): self.deleted_at = timezone.now() self.is_active = False self.fullname = f'{self.fullname}:deleted' if self.fullname else 'deleted_user' number = str(random.randint(1000000000, 9999999999)) if self.phone_number: self.phone_number = f'{self.phone_number}:deleted{number}' if self.email: self.email = f'{self.email}:deleted{number}' if self.device_id: self.device_id = f'{self.device_id}:deleted{number}' self.save() def save(self, *args, **kwargs): if not self.username and self.email: self.username = self.email elif self.email and User.objects.filter(username=self.email).exclude(pk=self.pk).exists(): self.username = f'{self.email}:{self.id or random.randint(1000, 9999)}' # Maintain first_name, last_name, and fullname synchronization if self.first_name or self.last_name: self.fullname = f"{self.first_name or ''} {self.last_name or ''}".strip() elif self.fullname: parts = self.fullname.strip().split(maxsplit=1) if len(parts) == 2: self.first_name = parts[0] self.last_name = parts[1] elif len(parts) == 1: self.first_name = parts[0] self.last_name = "" if not self.role_id: try: from apps.account.models.role import Role default_role = Role.objects.filter(is_default=True).first() if default_role: self.role = default_role except Exception: pass return super().save(*args, **kwargs) def get_full_name(self): full = f"{self.first_name or ''} {self.last_name or ''}".strip() return full or self.fullname or self.email or "" def get_short_name(self): return self.first_name or self.fullname or self.email or "" @property def is_guest(self): return self.email is None def has_role(self, role_name): return self.groups.filter(name__iexact=f"{role_name} Group").exists() or self.user_type == role_name def has_perm_flag(self, perm_name: str) -> bool: """ Check effective boolean permission flag: 1. Superuser always returns True. 2. Custom permission override on the user takes precedence if set. 3. Falls back to assigned Role value if user has a role. 4. Otherwise returns False. """ if not self.is_active: return False if self.is_superuser: return True if self.custom_permissions and perm_name in self.custom_permissions: val = self.custom_permissions[perm_name] if val is not None: return bool(val) if self.role and hasattr(self.role, perm_name): return getattr(self.role, perm_name) return False @property def is_super_admin(self): return self.is_superuser or self.user_type == self.UserType.SUPER_ADMIN or (self.role and self.role.slug == 'super_admin') @property def is_regional_admin(self): return self.is_super_admin or self.user_type == self.UserType.REGIONAL_ADMIN or (self.role and self.role.slug == 'regional_admin') def can_manage_region(self, region) -> bool: """ Check if user has administrative rights over the specified region: - Super admins have global authority over all regions. - Regional admins / staff only have authority over their assigned managed_regions. - If no region is provided or user has no region rights, returns False. """ if not self.is_authenticated or not self.is_active: return False if self.is_superuser or self.is_super_admin: return True if not (self.is_regional_admin or self.has_perm_flag('can_manage_institutions') or self.managed_regions.exists()): return False if not region: return False region_id = region.id if hasattr(region, 'id') else region return self.managed_regions.filter(id=region_id).exists() def has_regional_perm(self, perm_name: str, region=None) -> bool: """ Check if user has a permission, optionally scoped to a region: - Super admin: always True. - If region is None: True if user holds the permission flag in general. - If region is provided: * Must have the permission flag (or be a regional admin). * Must have authority over that specific region via can_manage_region(region). * If regional_permissions is configured, checks permission in dict/list. """ if not self.is_authenticated or not self.is_active: return False if self.is_superuser or self.is_super_admin: return True has_flag = self.has_perm_flag(perm_name) or self.is_regional_admin if not has_flag: return False if self.regional_permissions: allowed = False # Check 1: direct key in dict (e.g. {"institutions.change": True} or {"can_manage_institutions": True}) if isinstance(self.regional_permissions, dict): if perm_name in self.regional_permissions: val = self.regional_permissions[perm_name] allowed = bool(val) # Check 2: category-action format (e.g. "institutions.change" -> category "institutions", action "change") elif '.' in perm_name: cat, action = perm_name.split('.', 1) if cat in self.regional_permissions: cat_perms = self.regional_permissions[cat] if isinstance(cat_perms, (list, tuple, set)): allowed = action in cat_perms elif isinstance(cat_perms, dict): allowed = bool(cat_perms.get(action)) elif isinstance(cat_perms, bool): allowed = cat_perms elif isinstance(self.regional_permissions, (list, tuple, set)): allowed = perm_name in self.regional_permissions if not allowed: return False if region is not None: return self.can_manage_region(region) return self.is_regional_admin or self.managed_regions.exists() def get_managed_regions_queryset(self): from apps.profiles.models import Region if self.is_superuser or self.is_super_admin: return Region.objects.all() if self.is_regional_admin or self.managed_regions.exists(): return self.managed_regions.all() return Region.objects.none() @property def is_institution_admin(self): return self.is_regional_admin or self.user_type == self.UserType.INSTITUTION_ADMIN or (self.role and self.role.slug in ['institution_admin', 'institution_owner']) @property def is_editor(self): return self.is_institution_admin or self.user_type == self.UserType.EDITOR or (self.role and self.role.slug == 'content_moderator') def is_super_admin_panel_user(self): return self.is_superuser or self.user_type == self.UserType.SUPER_ADMIN def is_admin_panel_user(self): return self.is_staff or self.is_superuser or self.has_perm_flag('can_access_admin_panel') or self.user_type in [ self.UserType.SUPER_ADMIN, self.UserType.ADMIN, self.UserType.REGIONAL_ADMIN, self.UserType.INSTITUTION_ADMIN, ] def is_professor_panel_user(self): return self.user_type == self.UserType.PROFESSOR def can_access_admin_panel(self): return ( self.is_staff or self.is_superuser or self.has_perm_flag('can_access_admin_panel') or self.user_type in [ self.UserType.SUPER_ADMIN, self.UserType.ADMIN, self.UserType.REGIONAL_ADMIN, self.UserType.INSTITUTION_ADMIN, self.UserType.EDITOR, ] ) @property def verification_status(self): latest = self.verification_documents.first() if latest: return latest.status return 'unverified' def _get_fernet(self): key_bytes = settings.SECRET_KEY.encode('utf-8') hashed_key = hashlib.sha256(key_bytes).digest() fernet_key = base64.urlsafe_b64encode(hashed_key) return Fernet(fernet_key) def set_plain_password(self, plain_password): if not plain_password: self.password_enc = '' return try: password_bytes = plain_password.encode('utf-8') fernet = self._get_fernet() encrypted_token = fernet.encrypt(password_bytes) self.password_enc = encrypted_token.decode('utf-8') except Exception: self.password_enc = '' def get_plain_password(self): if not self.password_enc: return '' try: encrypted_bytes = self.password_enc.encode('utf-8') fernet = self._get_fernet() decrypted_bytes = fernet.decrypt(encrypted_bytes) return decrypted_bytes.decode('utf-8') except Exception: return '' class Meta: ordering = ("-id",) verbose_name = _("User") verbose_name_plural = _("Users") class LoginHistory(models.Model): user = models.ForeignKey("account.User", on_delete=models.CASCADE, related_name='login_history', verbose_name=_('User')) lat = models.FloatField(verbose_name=_('Latitude'), null=True, blank=True) lon = models.FloatField(verbose_name=_('Longitude'), null=True, blank=True) country = models.CharField(max_length=255, verbose_name=_('Country'), null=True, blank=True) city = models.CharField(max_length=255, verbose_name=_('City'), null=True, blank=True) ip = models.CharField(max_length=255, null=True, blank=True, verbose_name=_('IP Address')) timezone = models.CharField(max_length=100, null=True, blank=True, verbose_name=_('Timezone')) user_agent = models.TextField(verbose_name=_('User Agent'), null=True, blank=True) device_os = models.CharField(max_length=16, null=True, blank=True, verbose_name=_('Device OS')) at_time = models.DateTimeField(auto_now_add=True, verbose_name=_('Timestamp')) class Meta: verbose_name = _('Login History') verbose_name_plural = _('Login Histories') ordering = ('-at_time',) def __str__(self): return f"{self.user} - {self.ip} ({self.at_time})" class LocationHistory(models.Model): user = models.ForeignKey("account.User", on_delete=models.CASCADE, related_name='location_history', verbose_name=_('User')) lat = models.FloatField(verbose_name=_('Latitude'), null=True, blank=True) lon = models.FloatField(verbose_name=_('Longitude'), null=True, blank=True) country = models.CharField(max_length=255, verbose_name=_('Country'), null=True, blank=True) city = models.CharField(max_length=255, verbose_name=_('City'), null=True, blank=True) selected_manually = models.BooleanField(default=False, verbose_name=_('Selected Manually')) ip = models.CharField(max_length=255, null=True, blank=True, verbose_name=_('IP Address')) timezone = models.CharField(null=True, blank=True, max_length=60, verbose_name=_('Timezone')) at_time = models.DateTimeField(auto_now_add=True, verbose_name=_('Timestamp')) class Meta: verbose_name = _('Location History') verbose_name_plural = _('Location History') ordering = ('-at_time',) def __str__(self): return f"{self.user} - {self.city}, {self.country}"