""" 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}"