Marwan Alwali 4d06ca4b5e update
2025-09-20 14:26:19 +03:00

782 lines
22 KiB
Python

"""
Accounts app models for hospital management system.
Provides user management, authentication, and authorization functionality.
"""
import uuid
from datetime import timedelta
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.core.validators import RegexValidator
from django.utils import timezone
from django.conf import settings
# class User(AbstractUser):
# """
# Extended user model for hospital management system.
# """
# ROLE_CHOICES = [
# ('SUPER_ADMIN', 'Super Administrator'),
# ('ADMIN', 'Administrator'),
# ('PHYSICIAN', 'Physician'),
# ('NURSE', 'Nurse'),
# ('NURSE_PRACTITIONER', 'Nurse Practitioner'),
# ('PHYSICIAN_ASSISTANT', 'Physician Assistant'),
# ('PHARMACIST', 'Pharmacist'),
# ('PHARMACY_TECH', 'Pharmacy Technician'),
# ('LAB_TECH', 'Laboratory Technician'),
# ('RADIOLOGIST', 'Radiologist'),
# ('RAD_TECH', 'Radiology Technician'),
# ('THERAPIST', 'Therapist'),
# ('SOCIAL_WORKER', 'Social Worker'),
# ('CASE_MANAGER', 'Case Manager'),
# ('BILLING_SPECIALIST', 'Billing Specialist'),
# ('REGISTRATION', 'Registration Staff'),
# ('SCHEDULER', 'Scheduler'),
# ('MEDICAL_ASSISTANT', 'Medical Assistant'),
# ('CLERICAL', 'Clerical Staff'),
# ('IT_SUPPORT', 'IT Support'),
# ('QUALITY_ASSURANCE', 'Quality Assurance'),
# ('COMPLIANCE', 'Compliance Officer'),
# ('SECURITY', 'Security'),
# ('MAINTENANCE', 'Maintenance'),
# ('VOLUNTEER', 'Volunteer'),
# ('STUDENT', 'Student'),
# ('RESEARCHER', 'Researcher'),
# ('CONSULTANT', 'Consultant'),
# ('VENDOR', 'Vendor'),
# ('GUEST', 'Guest'),
# ]
# THEME_CHOICES = [
# ('LIGHT', 'Light'),
# ('DARK', 'Dark'),
# ('AUTO', 'Auto'),
# ]
# # Basic Information
# user_id = models.UUIDField(
# default=uuid.uuid4,
# unique=True,
# editable=False,
# help_text='Unique user identifier'
# )
#
# # Tenant relationship
# tenant = models.ForeignKey(
# 'core.Tenant',
# on_delete=models.CASCADE,
# related_name='users',
# help_text='Organization tenant'
# )
#
# # Personal Information
# middle_name = models.CharField(
# max_length=150,
# blank=True,
# null=True,
# help_text='Middle name'
# )
# preferred_name = models.CharField(
# max_length=150,
# blank=True,
# null=True,
# help_text='Preferred name'
# )
#
# # Contact Information
# phone_number = models.CharField(
# max_length=20,
# blank=True,
# null=True,
# validators=[RegexValidator(
# regex=r'^\+?1?\d{9,15}$',
# message='Phone number must be entered in the format: "+999999999". Up to 15 digits allowed.'
# )],
# help_text='Primary phone number'
# )
# mobile_number = models.CharField(
# max_length=20,
# blank=True,
# null=True,
# validators=[RegexValidator(
# regex=r'^\+?1?\d{9,15}$',
# message='Phone number must be entered in the format: "+999999999". Up to 15 digits allowed.'
# )],
# help_text='Mobile phone number'
# )
#
# # Professional Information
# employee_id = models.CharField(
# max_length=50,
# blank=True,
# null=True,
# help_text='Employee ID'
# )
# department = models.ForeignKey(
# 'hr.Department',
# on_delete=models.SET_NULL,
# null=True,
# blank=True,
# related_name='users',
# help_text='Department'
# )
# job_title = models.CharField(
# max_length=100,
# blank=True,
# null=True,
# help_text='Job title'
# )
#
# # Role and Permissions
# role = models.CharField(
# max_length=50,
# choices=ROLE_CHOICES,
# default='CLERICAL'
# )
#
# # License and Certification
# license_number = models.CharField(
# max_length=100,
# blank=True,
# null=True,
# help_text='Professional license number'
# )
# license_state = models.CharField(
# max_length=50,
# blank=True,
# null=True,
# help_text='License issuing state'
# )
# license_expiry = models.DateField(
# blank=True,
# null=True,
# help_text='License expiry date'
# )
# dea_number = models.CharField(
# max_length=20,
# blank=True,
# null=True,
# help_text='DEA number for prescribing'
# )
# npi_number = models.CharField(
# max_length=10,
# blank=True,
# null=True,
# help_text='National Provider Identifier'
# )
#
# # Security Settings
# force_password_change = models.BooleanField(
# default=False,
# help_text='User must change password on next login'
# )
# password_expires_at = models.DateTimeField(
# blank=True,
# null=True,
# help_text='Password expiration date'
# )
# failed_login_attempts = models.PositiveIntegerField(
# default=0,
# help_text='Number of failed login attempts'
# )
# locked_until = models.DateTimeField(
# blank=True,
# null=True,
# help_text='Account locked until this time'
# )
# two_factor_enabled = models.BooleanField(
# default=False,
# help_text='Two-factor authentication enabled'
# )
#
# # Session Management
# max_concurrent_sessions = models.PositiveIntegerField(
# default=3,
# help_text='Maximum concurrent sessions allowed'
# )
# session_timeout_minutes = models.PositiveIntegerField(
# default=30,
# help_text='Session timeout in minutes'
# )
#
# # Preferences
# user_timezone = models.CharField(
# max_length=50,
# default='Asia/Riyadh',
# help_text='User timezone'
# )
# language = models.CharField(
# max_length=10,
# default='en',
# help_text='Preferred language'
# )
# theme = models.CharField(
# max_length=20,
# choices=THEME_CHOICES,
# default='LIGHT'
# )
#
# # Profile Information
# profile_picture = models.ImageField(
# upload_to='profile_pictures/',
# blank=True,
# null=True,
# help_text='Profile picture'
# )
# bio = models.TextField(
# blank=True,
# null=True,
# help_text='Professional bio'
# )
#
# # Status
# is_verified = models.BooleanField(
# default=False,
# help_text='User account is verified'
# )
# is_approved = models.BooleanField(
# default=False,
# help_text='User account is approved'
# )
# approval_date = models.DateTimeField(
# blank=True,
# null=True,
# help_text='Account approval date'
# )
# approved_by = models.ForeignKey(
# 'self',
# on_delete=models.SET_NULL,
# null=True,
# blank=True,
# related_name='approved_users',
# help_text='User who approved this account'
# )
#
# # Metadata
# created_at = models.DateTimeField(auto_now_add=True)
# updated_at = models.DateTimeField(auto_now=True)
# last_password_change = models.DateTimeField(
# default=timezone.now,
# help_text='Last password change date'
# )
#
# class Meta:
# db_table = 'accounts_user'
# verbose_name = 'User'
# verbose_name_plural = 'Users'
# ordering = ['last_name', 'first_name']
# indexes = [
# models.Index(fields=['tenant', 'role']),
# models.Index(fields=['employee_id']),
# models.Index(fields=['license_number']),
# models.Index(fields=['npi_number']),
# ]
#
# def __str__(self):
# return f"{self.get_full_name()} ({self.username})"
#
# def get_full_name(self):
# """
# Return the full name for the user.
# """
# if self.preferred_name:
# return f"{self.preferred_name} {self.last_name}"
# return super().get_full_name()
#
# def get_display_name(self):
# """
# Return the display name for the user.
# """
# full_name = self.get_full_name()
# if full_name.strip():
# return full_name
# return self.username
#
# @property
# def is_account_locked(self):
# """
# Check if account is currently locked.
# """
# if self.locked_until:
# return timezone.now() < self.locked_until
# return False
#
# @property
# def is_password_expired(self):
# """
# Check if password has expired.
# """
# if self.password_expires_at:
# return timezone.now() > self.password_expires_at
# return False
#
# @property
# def is_license_expired(self):
# """
# Check if professional license has expired.
# """
# if self.license_expiry:
# return timezone.now().date() > self.license_expiry
# return False
#
# def lock_account(self, duration_minutes=15):
# """
# Lock the user account for specified duration.
# """
# self.locked_until = timezone.now() + timezone.timedelta(minutes=duration_minutes)
# self.save(update_fields=['locked_until'])
#
# def unlock_account(self):
# """
# Unlock the user account.
# """
# self.locked_until = None
# self.failed_login_attempts = 0
# self.save(update_fields=['locked_until', 'failed_login_attempts'])
#
# def increment_failed_login(self):
# """
# Increment failed login attempts and lock if threshold reached.
# """
# self.failed_login_attempts += 1
#
# # Lock account after 5 failed attempts
# max_attempts = getattr(settings, 'HOSPITAL_SETTINGS', {}).get('MAX_LOGIN_ATTEMPTS', 5)
# if self.failed_login_attempts >= max_attempts:
# lockout_duration = getattr(settings, 'HOSPITAL_SETTINGS', {}).get('LOCKOUT_DURATION_MINUTES', 15)
# self.lock_account(lockout_duration)
#
# self.save(update_fields=['failed_login_attempts'])
#
# def reset_failed_login(self):
# """
# Reset failed login attempts.
# """
# self.failed_login_attempts = 0
# self.save(update_fields=['failed_login_attempts'])
class User(AbstractUser):
user_id = models.UUIDField(unique=True, default=uuid.uuid4, editable=False)
email = models.EmailField(unique=True)
tenant = models.ForeignKey('core.Tenant', on_delete=models.PROTECT, related_name='users')
force_password_change = models.BooleanField(default=False)
password_expires_at = models.DateTimeField(blank=True, null=True)
failed_login_attempts = models.PositiveIntegerField(default=0)
locked_until = models.DateTimeField(blank=True, null=True)
two_factor_enabled = models.BooleanField(default=False)
max_concurrent_sessions = models.PositiveIntegerField(default=3)
session_timeout_minutes = models.PositiveIntegerField(default=30)
last_password_change = models.DateTimeField(blank=True, null=True)
class Meta:
db_table = 'accounts_user'
ordering = ['last_name', 'first_name']
indexes = [
models.Index(fields=['tenant', 'email']),
models.Index(fields=['tenant', 'username']),
models.Index(fields=['tenant', 'is_active']),
]
def __str__(self):
full = super().get_full_name().strip()
return f"{full or self.username}"
# ---- Security helpers ----
@property
def is_account_locked(self) -> bool:
return bool(self.locked_until and timezone.now() < self.locked_until)
@property
def is_password_expired(self) -> bool:
return bool(self.password_expires_at and timezone.now() > self.password_expires_at)
def lock_account(self, duration_minutes: int = 15):
self.locked_until = timezone.now() + timedelta(minutes=duration_minutes)
self.save(update_fields=['locked_until'])
def unlock_account(self):
self.locked_until = None
self.failed_login_attempts = 0
self.save(update_fields=['locked_until', 'failed_login_attempts'])
def increment_failed_login(self, *, max_attempts: int | None = None, lockout_minutes: int | None = None):
"""
Increment failed login attempts and lock the account if threshold reached.
Defaults pulled from settings.HOSPITAL_SETTINGS if provided, else (5, 15).
"""
self.failed_login_attempts += 1
if max_attempts is None:
max_attempts = getattr(settings, 'HOSPITAL_SETTINGS', {}).get('MAX_LOGIN_ATTEMPTS', 5)
if lockout_minutes is None:
lockout_minutes = getattr(settings, 'HOSPITAL_SETTINGS', {}).get('LOCKOUT_DURATION_MINUTES', 15)
if self.failed_login_attempts >= max_attempts:
self.lock_account(lockout_minutes)
else:
self.save(update_fields=['failed_login_attempts'])
def reset_failed_login(self):
self.failed_login_attempts = 0
self.save(update_fields=['failed_login_attempts'])
def set_password(self, raw_password):
super().set_password(raw_password)
self.last_password_change = timezone.now()
class TwoFactorDevice(models.Model):
class DeviceType(models.TextChoices):
TOTP = 'TOTP', 'Time-based OTP (Authenticator App)'
SMS = 'SMS', 'SMS'
EMAIL = 'EMAIL', 'Email'
HARDWARE = 'HARDWARE', 'Hardware Token'
BACKUP = 'BACKUP', 'Backup Codes'
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='two_factor_devices'
)
# Device Information
device_id = models.UUIDField(
default=uuid.uuid4,
unique=True,
editable=False,
help_text='Unique device identifier'
)
name = models.CharField(
max_length=100,
help_text='Device name'
)
device_type = models.CharField(max_length=20,choices=DeviceType.choices)
# Device Configuration
secret_key = models.CharField(
max_length=200,
blank=True,
null=True,
help_text='Secret key for TOTP devices'
)
phone_number = models.CharField(
max_length=20,
blank=True,
null=True,
help_text='Phone number for SMS devices'
)
email_address = models.EmailField(
blank=True,
null=True,
help_text='Email address for email devices'
)
# Status
is_active = models.BooleanField(
default=True,
help_text='Device is active'
)
is_verified = models.BooleanField(
default=False,
help_text='Device is verified'
)
verified_at = models.DateTimeField(
blank=True,
null=True,
help_text='Device verification date'
)
# Usage Statistics
last_used_at = models.DateTimeField(
blank=True,
null=True,
help_text='Last time device was used'
)
usage_count = models.PositiveIntegerField(
default=0,
help_text='Number of times device was used'
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'accounts_two_factor_device'
verbose_name = 'Two Factor Device'
verbose_name_plural = 'Two Factor Devices'
ordering = ['-created_at']
def __str__(self):
return f"{self.user.username} - {self.name} ({self.device_type})"
class SocialAccount(models.Model):
"""
Social authentication accounts linked to users.
"""
class Provider(models.TextChoices):
GOOGLE = 'GOOGLE', 'Google'
MICROSOFT = 'MICROSOFT', 'Microsoft'
APPLE = 'APPLE', 'Apple'
FACEBOOK = 'FACEBOOK', 'Facebook'
LINKEDIN = 'LINKEDIN', 'LinkedIn'
GITHUB = 'GITHUB', 'GitHub'
OKTA = 'OKTA', 'Okta'
SAML = 'SAML', 'SAML'
LDAP = 'LDAP', 'LDAP'
# User relationship
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='social_accounts'
)
# Provider Information
provider = models.CharField(max_length=50,choices=Provider.choices)
provider_id = models.CharField(
max_length=200,
help_text='Provider user ID'
)
provider_email = models.EmailField(
blank=True,
null=True,
help_text='Email from provider'
)
# Account Information
display_name = models.CharField(
max_length=200,
blank=True,
null=True,
help_text='Display name from provider'
)
profile_url = models.URLField(
blank=True,
null=True,
help_text='Profile URL from provider'
)
avatar_url = models.URLField(
blank=True,
null=True,
help_text='Avatar URL from provider'
)
# Tokens
access_token = models.TextField(
blank=True,
null=True,
help_text='Access token from provider'
)
refresh_token = models.TextField(
blank=True,
null=True,
help_text='Refresh token from provider'
)
token_expires_at = models.DateTimeField(
blank=True,
null=True,
help_text='Token expiration date'
)
# Status
is_active = models.BooleanField(
default=True,
help_text='Social account is active'
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
last_login_at = models.DateTimeField(
blank=True,
null=True,
help_text='Last login using this social account'
)
class Meta:
db_table = 'accounts_social_account'
verbose_name = 'Social Account'
verbose_name_plural = 'Social Accounts'
unique_together = ['provider', 'provider_id']
ordering = ['-created_at']
def __str__(self):
return f"{self.user.username} - {self.provider}"
class UserSession(models.Model):
"""
User session tracking for security and audit purposes.
"""
class DeviceType(models.TextChoices):
DESKTOP = 'DESKTOP', 'Desktop'
MOBILE = 'MOBILE', 'Mobile'
TABLET = 'TABLET', 'Tablet'
UNKNOWN = 'UNKNOWN', 'Unknown'
class LoginMethod(models.TextChoices):
PASSWORD = 'PASSWORD', 'Password'
TWO_FACTOR = 'TWO_FACTOR', 'Two Factor'
SOCIAL = 'SOCIAL', 'Social Login'
SSO = 'SSO', 'Single Sign-On'
API_KEY = 'API_KEY', 'API Key'
# User relationship
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='user_sessions'
)
# Session Information
session_key = models.CharField(
max_length=40,
unique=True,
help_text='Django session key'
)
session_id = models.UUIDField(
default=uuid.uuid4,
unique=True,
editable=False,
help_text='Unique session identifier'
)
# Device Information
ip_address = models.GenericIPAddressField(
help_text='IP address'
)
user_agent = models.TextField(
help_text='User agent string'
)
device_type = models.CharField(
max_length=20,
choices=DeviceType.choices,
default='UNKNOWN'
)
browser = models.CharField(
max_length=100,
blank=True,
null=True,
help_text='Browser name and version'
)
operating_system = models.CharField(
max_length=100,
blank=True,
null=True,
help_text='Operating system'
)
# Location Information
country = models.CharField(
max_length=100,
blank=True,
null=True,
help_text='Country'
)
region = models.CharField(
max_length=100,
blank=True,
null=True,
help_text='Region/State'
)
city = models.CharField(
max_length=100,
blank=True,
null=True,
help_text='City'
)
# Session Status
is_active = models.BooleanField(
default=True,
help_text='Session is active'
)
login_method = models.CharField(
max_length=20,
choices=LoginMethod.choices,
default=LoginMethod.PASSWORD
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
last_activity_at = models.DateTimeField(auto_now=True)
expires_at = models.DateTimeField(
help_text='Session expiration time'
)
ended_at = models.DateTimeField(
blank=True,
null=True,
help_text='Session end time'
)
class Meta:
db_table = 'accounts_user_session'
verbose_name = 'User Session'
verbose_name_plural = 'User Sessions'
ordering = ['-created_at']
indexes = [
models.Index(fields=['user', 'is_active']),
models.Index(fields=['session_key']),
models.Index(fields=['ip_address']),
]
def __str__(self):
return f"{self.user.username} - {self.ip_address} - {self.created_at}"
@property
def is_expired(self):
"""
Check if session has expired.
"""
return timezone.now() > self.expires_at
def end_session(self):
"""
End the session.
"""
self.is_active = False
self.ended_at = timezone.now()
self.save(update_fields=['is_active', 'ended_at'])
class PasswordHistory(models.Model):
"""
Password history for users to prevent password reuse.
"""
# User relationship
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='password_history'
)
# Password Information
password_hash = models.CharField(
max_length=128,
help_text='Hashed password'
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'accounts_password_history'
verbose_name = 'Password History'
verbose_name_plural = 'Password History'
ordering = ['-created_at']
def __str__(self):
return f"{self.user.username} - {self.created_at}"