You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
374 lines
14 KiB
374 lines
14 KiB
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}")
|
|
)
|
|
|
|
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')
|
|
|
|
@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}"
|