836 lines
23 KiB
Python
836 lines
23 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):
|
|
"""
|
|
Minimal auth user for a multi-tenant app:
|
|
- Authentication core (from AbstractUser)
|
|
- Tenant link
|
|
- Security/session controls (lockout, password expiry, 2FA flag, session caps)
|
|
Everything else lives on hr.Employee.
|
|
"""
|
|
|
|
# Stable internal UUID
|
|
user_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
|
|
|
# Tenant (PROTECT = safer than cascading deletion)
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.PROTECT,
|
|
related_name='users',
|
|
help_text='Organization tenant',
|
|
)
|
|
|
|
username = models.CharField(
|
|
max_length=150,
|
|
unique=True,
|
|
help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.'
|
|
)
|
|
email = models.EmailField(blank=True, null=True)
|
|
|
|
# --- Security & session controls kept on User (auth-level concerns) ---
|
|
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'
|
|
)
|
|
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'
|
|
)
|
|
last_password_change = models.DateTimeField(
|
|
default=timezone.now,
|
|
help_text='Last password change date'
|
|
)
|
|
is_active = models.BooleanField(
|
|
default=True,
|
|
help_text='User account is active'
|
|
)
|
|
|
|
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', 'user_id']),
|
|
models.Index(fields=['tenant', 'is_active']),
|
|
]
|
|
|
|
def __str__(self):
|
|
full = super().get_full_name().strip()
|
|
return f"{full or self.username} (tenant={self.tenant_id})"
|
|
|
|
# ---- 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'])
|
|
|
|
|
|
class TwoFactorDevice(models.Model):
|
|
"""
|
|
Two-factor authentication devices for users.
|
|
"""
|
|
|
|
# User relationship
|
|
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=[
|
|
('TOTP', 'Time-based OTP (Authenticator App)'),
|
|
('SMS', 'SMS'),
|
|
('EMAIL', 'Email'),
|
|
('HARDWARE', 'Hardware Token'),
|
|
('BACKUP', 'Backup Codes'),
|
|
]
|
|
)
|
|
|
|
# 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.
|
|
"""
|
|
|
|
# User relationship
|
|
user = models.ForeignKey(
|
|
User,
|
|
on_delete=models.CASCADE,
|
|
related_name='social_accounts'
|
|
)
|
|
|
|
# Provider Information
|
|
provider = models.CharField(
|
|
max_length=50,
|
|
choices=[
|
|
('GOOGLE', 'Google'),
|
|
('MICROSOFT', 'Microsoft'),
|
|
('APPLE', 'Apple'),
|
|
('FACEBOOK', 'Facebook'),
|
|
('LINKEDIN', 'LinkedIn'),
|
|
('GITHUB', 'GitHub'),
|
|
('OKTA', 'Okta'),
|
|
('SAML', 'SAML'),
|
|
('LDAP', 'LDAP'),
|
|
]
|
|
)
|
|
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.
|
|
"""
|
|
|
|
# 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=[
|
|
('DESKTOP', 'Desktop'),
|
|
('MOBILE', 'Mobile'),
|
|
('TABLET', 'Tablet'),
|
|
('UNKNOWN', 'Unknown'),
|
|
],
|
|
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=[
|
|
('PASSWORD', 'Password'),
|
|
('TWO_FACTOR', 'Two Factor'),
|
|
('SOCIAL', 'Social Login'),
|
|
('SSO', 'Single Sign-On'),
|
|
('API_KEY', 'API Key'),
|
|
],
|
|
default='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}"
|
|
|