diff --git a/accounts/__pycache__/admin.cpython-312.pyc b/accounts/__pycache__/admin.cpython-312.pyc index 353b0dda..37b1e9ee 100644 Binary files a/accounts/__pycache__/admin.cpython-312.pyc and b/accounts/__pycache__/admin.cpython-312.pyc differ diff --git a/accounts/__pycache__/apps.cpython-312.pyc b/accounts/__pycache__/apps.cpython-312.pyc index 9926ed64..b356b5bd 100644 Binary files a/accounts/__pycache__/apps.cpython-312.pyc and b/accounts/__pycache__/apps.cpython-312.pyc differ diff --git a/accounts/__pycache__/forms.cpython-312.pyc b/accounts/__pycache__/forms.cpython-312.pyc index 6708f721..a19da83f 100644 Binary files a/accounts/__pycache__/forms.cpython-312.pyc and b/accounts/__pycache__/forms.cpython-312.pyc differ diff --git a/accounts/__pycache__/models.cpython-312.pyc b/accounts/__pycache__/models.cpython-312.pyc index e26db44a..37ec5c71 100644 Binary files a/accounts/__pycache__/models.cpython-312.pyc and b/accounts/__pycache__/models.cpython-312.pyc differ diff --git a/accounts/__pycache__/signals.cpython-312.pyc b/accounts/__pycache__/signals.cpython-312.pyc new file mode 100644 index 00000000..b029acb0 Binary files /dev/null and b/accounts/__pycache__/signals.cpython-312.pyc differ diff --git a/accounts/__pycache__/views.cpython-312.pyc b/accounts/__pycache__/views.cpython-312.pyc index 44e99a6d..81b4fe72 100644 Binary files a/accounts/__pycache__/views.cpython-312.pyc and b/accounts/__pycache__/views.cpython-312.pyc differ diff --git a/accounts/admin.py b/accounts/admin.py index 6efff1a0..72aee308 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -3,74 +3,44 @@ Admin configuration for accounts app. """ from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin + from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.utils.html import format_html from .models import User, TwoFactorDevice, SocialAccount, UserSession, PasswordHistory +from hr.models import Employee + +class EmployeeInline(admin.StackedInline): + model = Employee + can_delete = False + fk_name = 'user' + extra = 0 @admin.register(User) -class UserAdmin(BaseUserAdmin): - """ - Admin configuration for User model. - """ - list_display = [ - 'username', 'email', 'get_full_name', 'role', 'tenant', - 'is_active', 'is_verified', 'is_approved', 'last_login' - ] - list_filter = [ - 'role', 'tenant', 'is_active', 'is_verified', 'is_approved', - 'two_factor_enabled', 'is_staff', 'is_superuser' - ] - search_fields = [ - 'username', 'email', 'first_name', 'last_name', - 'employee_id', 'license_number', 'npi_number' - ] - ordering = ['last_name', 'first_name'] - - fieldsets = BaseUserAdmin.fieldsets + ( - ('Tenant Information', { - 'fields': ('tenant', 'user_id') - }), - ('Personal Information', { - 'fields': ( - 'middle_name', 'preferred_name', 'phone_number', 'mobile_number', - 'profile_picture', 'bio' - ) - }), - ('Professional Information', { - 'fields': ( - 'employee_id', 'department', 'job_title', 'role', - 'license_number', 'license_state', 'license_expiry', - 'dea_number', 'npi_number' - ) - }), - ('Security Settings', { - 'fields': ( - 'force_password_change', 'password_expires_at', - 'failed_login_attempts', 'locked_until', 'two_factor_enabled', - 'max_concurrent_sessions', 'session_timeout_minutes' - ) - }), - ('Preferences', { - 'fields': ( 'language', 'theme') - }), - ('Status', { - 'fields': ( - 'is_verified', 'is_approved', 'approval_date', 'approved_by' - ) - }), - ('Metadata', { - 'fields': ('created_at', 'updated_at', 'last_password_change'), - 'classes': ('collapse',) +class UserAdmin(DjangoUserAdmin): + inlines = [EmployeeInline] + list_display = ('username', 'email', 'tenant', 'is_active', 'is_staff', 'two_factor_enabled', 'locked_until') + list_filter = ('tenant', 'is_active', 'is_staff', 'is_superuser', 'two_factor_enabled') + search_fields = ('username', 'email', 'first_name', 'last_name') + readonly_fields = ('last_login', 'date_joined', 'last_password_change') + fieldsets = ( + (None, {'fields': ('tenant', 'username', 'password')}), + ('Personal info', {'fields': ('first_name', 'last_name', 'email')}), + ('Security', {'fields': ( + 'force_password_change', 'password_expires_at', 'last_password_change', + 'failed_login_attempts', 'locked_until', 'two_factor_enabled', + 'max_concurrent_sessions', 'session_timeout_minutes', + )}), + ('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}), + ('Important dates', {'fields': ('last_login', 'date_joined')}), + ) + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('tenant', 'username', 'email', 'password1', 'password2'), }), ) - - readonly_fields = [ - 'user_id', 'created_at', 'updated_at', 'last_password_change' - ] - - def get_queryset(self, request): - return super().get_queryset(request).select_related('tenant', 'approved_by') @admin.register(TwoFactorDevice) @@ -85,7 +55,7 @@ class TwoFactorDeviceAdmin(admin.ModelAdmin): list_filter = ['device_type', 'is_active', 'is_verified'] search_fields = ['user__username', 'user__email', 'name'] ordering = ['-created_at'] - + fieldsets = ( ('Device Information', { 'fields': ('user', 'device_id', 'name', 'device_type') @@ -104,7 +74,7 @@ class TwoFactorDeviceAdmin(admin.ModelAdmin): 'classes': ('collapse',) }), ) - + readonly_fields = ['device_id', 'created_at', 'updated_at'] @@ -123,7 +93,7 @@ class SocialAccountAdmin(admin.ModelAdmin): 'display_name', 'provider_id' ] ordering = ['-created_at'] - + fieldsets = ( ('User Information', { 'fields': ('user',) @@ -146,7 +116,7 @@ class SocialAccountAdmin(admin.ModelAdmin): 'classes': ('collapse',) }), ) - + readonly_fields = ['created_at', 'updated_at'] @@ -168,7 +138,7 @@ class UserSessionAdmin(admin.ModelAdmin): 'user_agent', 'browser', 'operating_system' ] ordering = ['-created_at'] - + fieldsets = ( ('User Information', { 'fields': ('user', 'session_key', 'session_id') @@ -192,11 +162,11 @@ class UserSessionAdmin(admin.ModelAdmin): ) }), ) - + readonly_fields = [ 'session_id', 'created_at', 'last_activity_at' ] - + def get_queryset(self, request): return super().get_queryset(request).select_related('user') @@ -210,7 +180,7 @@ class PasswordHistoryAdmin(admin.ModelAdmin): list_filter = ['created_at'] search_fields = ['user__username', 'user__email'] ordering = ['-created_at'] - + fieldsets = ( ('User Information', { 'fields': ('user',) @@ -222,9 +192,9 @@ class PasswordHistoryAdmin(admin.ModelAdmin): 'fields': ('created_at',) }), ) - + readonly_fields = ['created_at'] - + def get_queryset(self, request): return super().get_queryset(request).select_related('user') diff --git a/accounts/apps.py b/accounts/apps.py index 3e3c7659..a8509d73 100644 --- a/accounts/apps.py +++ b/accounts/apps.py @@ -4,3 +4,6 @@ from django.apps import AppConfig class AccountsConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'accounts' + + def ready(self): + from . import signals diff --git a/accounts/forms.py b/accounts/forms.py index 229d940f..e12f9048 100644 --- a/accounts/forms.py +++ b/accounts/forms.py @@ -15,25 +15,23 @@ class UserForm(forms.ModelForm): class Meta: model = User fields = [ - 'first_name', 'last_name', 'email', 'phone_number', 'mobile_number', - 'employee_id', 'role', 'department', 'bio', 'user_timezone', 'language', - 'theme', 'is_active', 'is_approved' + 'email', ] widgets = { - 'first_name': forms.TextInput(attrs={'class': 'form-control'}), - 'last_name': forms.TextInput(attrs={'class': 'form-control'}), + # 'first_name': forms.TextInput(attrs={'class': 'form-control'}), + # 'last_name': forms.TextInput(attrs={'class': 'form-control'}), 'email': forms.EmailInput(attrs={'class': 'form-control'}), - 'phone_number': forms.TextInput(attrs={'class': 'form-control'}), - 'mobile_number': forms.TextInput(attrs={'class': 'form-control'}), - 'employee_id': forms.TextInput(attrs={'class': 'form-control'}), - 'role': forms.Select(attrs={'class': 'form-select'}), - 'department': forms.TextInput(attrs={'class': 'form-control'}), - 'bio': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), - 'user_timezone': forms.Select(attrs={'class': 'form-select'}), - 'language': forms.Select(attrs={'class': 'form-select'}), - 'theme': forms.Select(attrs={'class': 'form-select'}), - 'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}), - 'is_approved': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + # 'phone_number': forms.TextInput(attrs={'class': 'form-control'}), + # 'mobile_number': forms.TextInput(attrs={'class': 'form-control'}), + # 'employee_id': forms.TextInput(attrs={'class': 'form-control'}), + # 'role': forms.Select(attrs={'class': 'form-select'}), + # 'department': forms.TextInput(attrs={'class': 'form-control'}), + # 'bio': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), + # 'user_timezone': forms.Select(attrs={'class': 'form-select'}), + # 'language': forms.Select(attrs={'class': 'form-select'}), + # 'theme': forms.Select(attrs={'class': 'form-select'}), + # 'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + # 'is_approved': forms.CheckboxInput(attrs={'class': 'form-check-input'}), } @@ -45,23 +43,22 @@ class UserCreateForm(UserCreationForm): last_name = forms.CharField(max_length=150, required=True) email = forms.EmailField(required=True) employee_id = forms.CharField(max_length=50, required=False) - role = forms.ChoiceField(choices=User._meta.get_field('role').choices, required=True) + # role = forms.ChoiceField(choices=User._meta.get_field('role').choices, required=True) department = forms.CharField(max_length=100, required=False) class Meta: model = User fields = [ - 'username', 'first_name', 'last_name', 'email', 'employee_id', - 'role', 'department', 'password1', 'password2' + 'username', 'first_name', 'last_name', 'email', 'password1', 'password2' ] widgets = { 'username': forms.TextInput(attrs={'class': 'form-control'}), 'first_name': forms.TextInput(attrs={'class': 'form-control'}), 'last_name': forms.TextInput(attrs={'class': 'form-control'}), 'email': forms.EmailInput(attrs={'class': 'form-control'}), - 'employee_id': forms.TextInput(attrs={'class': 'form-control'}), - 'role': forms.Select(attrs={'class': 'form-select'}), - 'department': forms.TextInput(attrs={'class': 'form-control'}), + # 'employee_id': forms.TextInput(attrs={'class': 'form-control'}), + # 'role': forms.Select(attrs={'class': 'form-select'}), + # 'department': forms.TextInput(attrs={'class': 'form-control'}), } def __init__(self, *args, **kwargs): @@ -148,11 +145,11 @@ class AccountsSearchForm(forms.Form): 'placeholder': 'Search users, sessions, devices...' }) ) - role = forms.ChoiceField( - choices=[('', 'All Roles')] + list(User._meta.get_field('role').choices), - required=False, - widget=forms.Select(attrs={'class': 'form-select'}) - ) + # role = forms.ChoiceField( + # choices=[('', 'All Roles')] + list(User._meta.get_field('role').choices), + # required=False, + # widget=forms.Select(attrs={'class': 'form-select'}) + # ) status = forms.ChoiceField( choices=[ ('', 'All Status'), diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py index bdd6329e..3b1f7452 100644 --- a/accounts/migrations/0001_initial.py +++ b/accounts/migrations/0001_initial.py @@ -1,12 +1,8 @@ -# Generated by Django 5.2.6 on 2025-09-08 07:28 +# Generated by Django 5.2.6 on 2025-09-15 14:05 import django.contrib.auth.models -import django.contrib.auth.validators -import django.core.validators -import django.db.models.deletion import django.utils.timezone import uuid -from django.conf import settings from django.db import migrations, models @@ -390,21 +386,6 @@ class Migration(migrations.Migration): verbose_name="superuser status", ), ), - ( - "username", - models.CharField( - error_messages={ - "unique": "A user with that username already exists." - }, - help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", - max_length=150, - unique=True, - validators=[ - django.contrib.auth.validators.UnicodeUsernameValidator() - ], - verbose_name="username", - ), - ), ( "first_name", models.CharField( @@ -417,12 +398,6 @@ class Migration(migrations.Migration): blank=True, max_length=150, verbose_name="last name" ), ), - ( - "email", - models.EmailField( - blank=True, max_length=254, verbose_name="email address" - ), - ), ( "is_staff", models.BooleanField( @@ -447,157 +422,17 @@ class Migration(migrations.Migration): ), ( "user_id", - models.UUIDField( - default=uuid.uuid4, - editable=False, - help_text="Unique user identifier", + models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ( + "username", + models.CharField( + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, unique=True, ), ), - ( - "middle_name", - models.CharField( - blank=True, help_text="Middle name", max_length=150, null=True - ), - ), - ( - "preferred_name", - models.CharField( - blank=True, - help_text="Preferred name", - max_length=150, - null=True, - ), - ), - ( - "phone_number", - models.CharField( - blank=True, - help_text="Primary phone number", - max_length=20, - null=True, - validators=[ - django.core.validators.RegexValidator( - message='Phone number must be entered in the format: "+999999999". Up to 15 digits allowed.', - regex="^\\+?1?\\d{9,15}$", - ) - ], - ), - ), - ( - "mobile_number", - models.CharField( - blank=True, - help_text="Mobile phone number", - max_length=20, - null=True, - validators=[ - django.core.validators.RegexValidator( - message='Phone number must be entered in the format: "+999999999". Up to 15 digits allowed.', - regex="^\\+?1?\\d{9,15}$", - ) - ], - ), - ), - ( - "employee_id", - models.CharField( - blank=True, help_text="Employee ID", max_length=50, null=True - ), - ), - ( - "department", - models.CharField( - blank=True, help_text="Department", max_length=100, null=True - ), - ), - ( - "job_title", - models.CharField( - blank=True, help_text="Job title", max_length=100, null=True - ), - ), - ( - "role", - models.CharField( - 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"), - ], - default="CLERICAL", - max_length=50, - ), - ), - ( - "license_number", - models.CharField( - blank=True, - help_text="Professional license number", - max_length=100, - null=True, - ), - ), - ( - "license_state", - models.CharField( - blank=True, - help_text="License issuing state", - max_length=50, - null=True, - ), - ), - ( - "license_expiry", - models.DateField( - blank=True, help_text="License expiry date", null=True - ), - ), - ( - "dea_number", - models.CharField( - blank=True, - help_text="DEA number for prescribing", - max_length=20, - null=True, - ), - ), - ( - "npi_number", - models.CharField( - blank=True, - help_text="National Provider Identifier", - max_length=10, - null=True, - ), - ), + ("email", models.EmailField(blank=True, max_length=254, null=True)), ( "force_password_change", models.BooleanField( @@ -643,65 +478,6 @@ class Migration(migrations.Migration): default=30, help_text="Session timeout in minutes" ), ), - ( - "user_timezone", - models.CharField( - default="UTC", help_text="User timezone", max_length=50 - ), - ), - ( - "language", - models.CharField( - default="en", help_text="Preferred language", max_length=10 - ), - ), - ( - "theme", - models.CharField( - choices=[ - ("LIGHT", "Light"), - ("DARK", "Dark"), - ("AUTO", "Auto"), - ], - default="LIGHT", - max_length=20, - ), - ), - ( - "profile_picture", - models.ImageField( - blank=True, - help_text="Profile picture", - null=True, - upload_to="profile_pictures/", - ), - ), - ( - "bio", - models.TextField( - blank=True, help_text="Professional bio", null=True - ), - ), - ( - "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, help_text="Account approval date", null=True - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), ( "last_password_change", models.DateTimeField( @@ -709,17 +485,6 @@ class Migration(migrations.Migration): help_text="Last password change date", ), ), - ( - "approved_by", - models.ForeignKey( - blank=True, - help_text="User who approved this account", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="approved_users", - to=settings.AUTH_USER_MODEL, - ), - ), ( "groups", models.ManyToManyField( @@ -733,8 +498,6 @@ class Migration(migrations.Migration): ), ], options={ - "verbose_name": "User", - "verbose_name_plural": "Users", "db_table": "accounts_user", "ordering": ["last_name", "first_name"], }, diff --git a/accounts/migrations/0002_initial.py b/accounts/migrations/0002_initial.py index 507b4a6b..f21f378f 100644 --- a/accounts/migrations/0002_initial.py +++ b/accounts/migrations/0002_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-09-08 07:28 +# Generated by Django 5.2.6 on 2025-09-15 14:05 import django.db.models.deletion from django.conf import settings @@ -21,7 +21,7 @@ class Migration(migrations.Migration): name="tenant", field=models.ForeignKey( help_text="Organization tenant", - on_delete=django.db.models.deletion.CASCADE, + on_delete=django.db.models.deletion.PROTECT, related_name="users", to="core.tenant", ), @@ -77,25 +77,25 @@ class Migration(migrations.Migration): migrations.AddIndex( model_name="user", index=models.Index( - fields=["tenant", "role"], name="accounts_us_tenant__731b87_idx" + fields=["tenant", "email"], name="accounts_us_tenant__162cd2_idx" ), ), migrations.AddIndex( model_name="user", index=models.Index( - fields=["employee_id"], name="accounts_us_employe_0cbd94_idx" + fields=["tenant", "username"], name="accounts_us_tenant__d92906_idx" ), ), migrations.AddIndex( model_name="user", index=models.Index( - fields=["license_number"], name="accounts_us_license_02eb85_idx" + fields=["tenant", "user_id"], name="accounts_us_tenant__bd3758_idx" ), ), migrations.AddIndex( model_name="user", index=models.Index( - fields=["npi_number"], name="accounts_us_npi_num_800ef1_idx" + fields=["tenant", "is_active"], name="accounts_us_tenant__78e6c9_idx" ), ), migrations.AlterUniqueTogether( diff --git a/accounts/migrations/0003_alter_user_is_active.py b/accounts/migrations/0003_alter_user_is_active.py new file mode 100644 index 00000000..5132af3d --- /dev/null +++ b/accounts/migrations/0003_alter_user_is_active.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.6 on 2025-09-15 14:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0002_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="is_active", + field=models.BooleanField(default=True, help_text="User account is active"), + ), + ] diff --git a/accounts/migrations/__pycache__/0001_initial.cpython-312.pyc b/accounts/migrations/__pycache__/0001_initial.cpython-312.pyc index 9eeff7d2..05f05ec0 100644 Binary files a/accounts/migrations/__pycache__/0001_initial.cpython-312.pyc and b/accounts/migrations/__pycache__/0001_initial.cpython-312.pyc differ diff --git a/accounts/migrations/__pycache__/0002_initial.cpython-312.pyc b/accounts/migrations/__pycache__/0002_initial.cpython-312.pyc index b831cc21..266b123a 100644 Binary files a/accounts/migrations/__pycache__/0002_initial.cpython-312.pyc and b/accounts/migrations/__pycache__/0002_initial.cpython-312.pyc differ diff --git a/accounts/migrations/__pycache__/0003_alter_user_is_active.cpython-312.pyc b/accounts/migrations/__pycache__/0003_alter_user_is_active.cpython-312.pyc new file mode 100644 index 00000000..294c6c47 Binary files /dev/null and b/accounts/migrations/__pycache__/0003_alter_user_is_active.cpython-312.pyc differ diff --git a/accounts/models.py b/accounts/models.py index d1bc676b..7678d925 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -4,6 +4,8 @@ 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 @@ -11,158 +13,379 @@ 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): """ - Extended user model for hospital management system. + 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. """ - 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 + + # 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.CASCADE, + on_delete=models.PROTECT, related_name='users', - help_text='Organization tenant' + help_text='Organization tenant', ) - - # Personal Information - middle_name = models.CharField( + + username = models.CharField( max_length=150, - blank=True, - null=True, - help_text='Middle name' + unique=True, + help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.' ) - 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.CharField( - max_length=100, - blank=True, - null=True, - 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 + 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' @@ -185,8 +408,6 @@ class User(AbstractUser): default=False, help_text='Two-factor authentication enabled' ) - - # Session Management max_concurrent_sessions = models.PositiveIntegerField( default=3, help_text='Maximum concurrent sessions allowed' @@ -195,160 +416,64 @@ class User(AbstractUser): default=30, help_text='Session timeout in minutes' ) - - # Preferences - user_timezone = models.CharField( - max_length=50, - default='UTC', - 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' ) - + is_active = models.BooleanField( + default=True, + help_text='User account is active' + ) + 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']), + 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): - 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 - + full = super().get_full_name().strip() + return f"{full or self.username} (tenant={self.tenant_id})" + + # ---- Security helpers ---- @property - def is_account_locked(self): - """ - Check if account is currently locked. - """ - if self.locked_until: - return timezone.now() < self.locked_until - return False - + def is_account_locked(self) -> bool: + return bool(self.locked_until and timezone.now() < self.locked_until) + @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) + 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): - """ - 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): + + def increment_failed_login(self, *, max_attempts: int | None = None, lockout_minutes: int | None = None): """ - Increment failed login attempts and lock if threshold reached. + 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 - - # Lock account after 5 failed attempts - max_attempts = getattr(settings, 'HOSPITAL_SETTINGS', {}).get('MAX_LOGIN_ATTEMPTS', 5) + 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: - lockout_duration = getattr(settings, 'HOSPITAL_SETTINGS', {}).get('LOCKOUT_DURATION_MINUTES', 15) - self.lock_account(lockout_duration) - - self.save(update_fields=['failed_login_attempts']) - + self.lock_account(lockout_minutes) + else: + 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']) diff --git a/accounts/signals.py b/accounts/signals.py new file mode 100644 index 00000000..97b1f6ff --- /dev/null +++ b/accounts/signals.py @@ -0,0 +1,41 @@ +# accounts/signals.py +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.db import transaction +from django.utils.text import slugify + +from hr.models import Employee +from .models import User + + +@receiver(post_save, sender=User) +def create_employee_for_user(sender, instance: User, created: bool, **kwargs): + """ + Auto-create an Employee profile when a User is created. + Idempotent, tenant-aligned, and makes a unique employee_number per tenant. + """ + if not created: + return + + def _make_employee(): + # Generate a readable employee_number from username/email, unique within tenant + base = (instance.username or (instance.email or 'user')).split('@')[0] + candidate = slugify(base)[:16] or 'emp' + suffix = 1 + emp_no = candidate + while Employee.objects.filter(tenant=instance.tenant, employee_number=emp_no).exists(): + suffix += 1 + emp_no = f"{candidate}-{suffix}" + + # Create with basic info mirrored from User + Employee.objects.create( + tenant=instance.tenant, + user=instance, + employee_number=emp_no, + first_name=instance.first_name or '', + last_name=instance.last_name or '', + email=instance.email + ) + + # Defer until after outer transaction commits (avoids race conditions in tests/views) + transaction.on_commit(_make_employee) \ No newline at end of file diff --git a/accounts/templates/account/user_profile.html b/accounts/templates/account/user_profile.html index 8a6af827..bcc8ae0d 100644 --- a/accounts/templates/account/user_profile.html +++ b/accounts/templates/account/user_profile.html @@ -124,8 +124,8 @@
- {% if user.profile_picture %} - {{ user.get_full_name }} + {% if user.employee_profile.profile_picture %} + {{ user.employee_profile.get_full_name }} {% else %}
@@ -138,8 +138,8 @@
-

{{ user.get_full_name }}

-

{{ user.role|title }} • {{ user.department|default:"No Department" }}

+

{{ user.employee_profile.get_full_name }}

+

{{ user.employee_profile.get_role_display }} • {{ user.employee_profile.department|default:"No Department" }}

@@ -167,14 +167,14 @@
+ value="{{ user.employee_profile.first_name }}" required>
+ value="{{ user.employee_profile.last_name }}" required>
@@ -184,14 +184,14 @@
+ value="{{ user.employee_profile.email }}" required>
- - + +
@@ -199,9 +199,9 @@
- - + +
@@ -209,7 +209,7 @@
+ placeholder="Tell us about yourself...">{{ user.employee_profile.bio }}
@@ -219,29 +219,29 @@
diff --git a/accounts/views.py b/accounts/views.py index 615079e8..d6812db6 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -18,6 +18,8 @@ from django.utils import timezone from django.urls import reverse_lazy, reverse from django.core.paginator import Paginator from datetime import timedelta + +from hr.models import Employee from .models import User, TwoFactorDevice, SocialAccount, UserSession, PasswordHistory from .forms import ( UserForm, UserCreateForm, TwoFactorDeviceForm, SocialAccountForm, @@ -26,17 +28,6 @@ from .forms import ( from core.utils import AuditLogger - -# ============================================================================ -# USER VIEWS (FULL CRUD - Master Data) -# ============================================================================ession, PasswordHistory -from .forms import ( - UserForm, UserCreateForm, TwoFactorDeviceForm, SocialAccountForm, - AccountsSearchForm, PasswordChangeForm -) -from core.utils import AuditLogger - - # ============================================================================ # USER VIEWS (FULL CRUD - Master Data) # ============================================================================ @@ -1024,37 +1015,38 @@ def user_profile_update(request): HTMX view for user profile update. """ if request.method == 'POST': - user = request.user + employee = Employee.objects.get(user=request.user) # Update basic information - user.first_name = request.POST.get('first_name', user.first_name) - user.last_name = request.POST.get('last_name', user.last_name) - user.email = request.POST.get('email', user.email) - user.phone_number = request.POST.get('phone_number', user.phone_number) - user.mobile_number = request.POST.get('mobile_number', user.mobile_number) - user.bio = request.POST.get('bio', user.bio) - + employee.first_name = request.POST.get('first_name', employee.first_name) + employee.last_name = request.POST.get('last_name', employee.last_name) + employee.email = request.POST.get('email', employee.email) + employee.phone = request.POST.get('phone', employee.phone) + employee.mobile_phone = request.POST.get('mobile_phone', employee.mobile_phone) + employee.bio = request.POST.get('bio', employee.bio) + # Update preferences - user.timezone = request.POST.get('timezone', user.timezone) - user.language = request.POST.get('language', user.language) - user.theme = request.POST.get('theme', user.theme) - - user.save() - + employee.user_timezone = request.POST.get('timezone', employee.user_timezone) + employee.language = request.POST.get('language', employee.language) + employee.theme = request.POST.get('theme', employee.theme) + + employee.save() + # Log the update AuditLogger.log_event( tenant=getattr(request, 'tenant', None), event_type='UPDATE', event_category='DATA_MODIFICATION', action='Update User Profile', - description=f'User updated their profile: {user.username}', + description=f'User updated their profile: {employee.user.username}', user=request.user, - content_object=user, + content_object=employee, request=request ) - + messages.success(request, 'Profile updated successfully.') - return JsonResponse({'status': 'success'}) + return redirect('accounts:user_profile') + return JsonResponse({'error': 'Invalid request'}, status=400) diff --git a/accounts_data.py b/accounts_data.py index 838d0daf..fd5834b8 100644 --- a/accounts_data.py +++ b/accounts_data.py @@ -1,3 +1,5 @@ +# scripts/seed_saudi_accounts.py + import os import django @@ -5,56 +7,59 @@ import django os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'hospital_management.settings') django.setup() - import random -from datetime import datetime, timedelta, timezone -from django.contrib.auth.hashers import make_password -from django.utils import timezone as django_timezone -from accounts.models import User, TwoFactorDevice, SocialAccount, UserSession, PasswordHistory -from core.models import Tenant import uuid import secrets +from datetime import timedelta +from django.contrib.auth.hashers import make_password +from django.utils import timezone as django_timezone +from django.db import transaction +from accounts.models import User +from core.models import Tenant +from hr.models import Employee +from hr.models import Department # make sure hr.Department exists + +# If these exist in your project, keep them. If not, comment them out. +from accounts.models import TwoFactorDevice, SocialAccount, UserSession, PasswordHistory # noqa + +# ------------------------------- # Saudi-specific data constants +# ------------------------------- SAUDI_FIRST_NAMES_MALE = [ 'Mohammed', 'Abdullah', 'Ahmed', 'Omar', 'Ali', 'Hassan', 'Khalid', 'Faisal', 'Saad', 'Fahd', 'Bandar', 'Turki', 'Nasser', 'Saud', 'Abdulrahman', 'Abdulaziz', 'Salman', 'Waleed', 'Majid', 'Rayan', 'Yazeed', 'Mansour', 'Osama', 'Tariq', 'Adel', 'Nawaf', 'Sultan', 'Mishaal', 'Badr', 'Ziad' ] - SAUDI_FIRST_NAMES_FEMALE = [ 'Fatima', 'Aisha', 'Maryam', 'Khadija', 'Sarah', 'Noura', 'Hala', 'Reem', 'Lina', 'Dana', 'Rana', 'Nada', 'Layla', 'Amira', 'Zahra', 'Yasmin', 'Dina', 'Noor', 'Rahma', 'Salma', 'Lama', 'Ghada', 'Rania', 'Maha', 'Wedad', 'Najla', 'Shahd', 'Jood', 'Rand', 'Malak' ] - SAUDI_FAMILY_NAMES = [ 'Al-Rashid', 'Al-Harbi', 'Al-Qahtani', 'Al-Dosari', 'Al-Otaibi', 'Al-Mutairi', 'Al-Shammari', 'Al-Zahrani', 'Al-Ghamdi', 'Al-Maliki', 'Al-Subai', 'Al-Jubayr', - 'Al-Faisal', 'Al-Saud', 'Al-Thani', 'Al-Maktoum', 'Al-Sabah', 'Al-Khalifa', - 'Bin-Laden', 'Al-Rajhi', 'Al-Sudairy', 'Al-Shaalan', 'Al-Kabeer', 'Al-Ajmi', + 'Al-Faisal', 'Al-Saud', 'Al-Shaalan', 'Al-Rajhi', 'Al-Sudairy', 'Al-Ajmi', 'Al-Anzi', 'Al-Dawsari', 'Al-Shamrani', 'Al-Balawi', 'Al-Juhani', 'Al-Sulami' ] - -SAUDI_MIDDLE_NAMES = [ - 'bin Ahmed', 'bin Mohammed', 'bin Abdullah', 'bin Omar', 'bin Ali', 'bin Hassan', - 'bin Khalid', 'bin Faisal', 'bin Saad', 'bin Fahd', 'bin Abdulaziz', 'bin Salman' -] - SAUDI_CITIES = [ 'Riyadh', 'Jeddah', 'Mecca', 'Medina', 'Dammam', 'Khobar', 'Dhahran', 'Taif', 'Tabuk', 'Buraidah', 'Khamis Mushait', 'Hofuf', 'Mubarraz', 'Jubail', 'Yanbu', 'Abha', 'Najran', 'Jazan', 'Hail', 'Arar' ] - SAUDI_PROVINCES = [ 'Riyadh Province', 'Makkah Province', 'Eastern Province', 'Asir Province', 'Jazan Province', 'Medina Province', 'Qassim Province', 'Tabuk Province', 'Hail Province', 'Northern Borders Province', 'Najran Province', 'Al Bahah Province' ] - +SAUDI_DEPARTMENTS = [ + 'Internal Medicine', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', + 'Pediatrics', 'Emergency Medicine', 'Radiology', 'Laboratory Medicine', + 'Pharmacy', 'Surgery', 'Obstetrics and Gynecology', 'Dermatology', + 'Ophthalmology', 'ENT', 'Anesthesiology', 'Pathology', 'Psychiatry' +] SAUDI_JOB_TITLES = { 'PHYSICIAN': ['Consultant Physician', 'Senior Physician', 'Staff Physician', 'Resident Physician', 'Chief Medical Officer'], @@ -63,177 +68,189 @@ SAUDI_JOB_TITLES = { 'ADMIN': ['Medical Director', 'Hospital Administrator', 'Department Manager', 'Operations Manager'], 'LAB_TECH': ['Senior Lab Technician', 'Medical Laboratory Scientist', 'Lab Supervisor'], 'RAD_TECH': ['Senior Radiologic Technologist', 'CT Technologist', 'MRI Technologist'], - 'RADIOLOGIST': ['Consultant Radiologist', 'Senior Radiologist', 'Interventional Radiologist'] + 'RADIOLOGIST': ['Consultant Radiologist', 'Senior Radiologist', 'Interventional Radiologist'], + 'MEDICAL_ASSISTANT': ['Medical Assistant'], + 'CLERICAL': ['Clerical Staff'], } - -SAUDI_DEPARTMENTS = [ - 'Internal Medicine', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', - 'Pediatrics', 'Emergency Medicine', 'Radiology', 'Laboratory Medicine', - 'Pharmacy', 'Surgery', 'Obstetrics and Gynecology', 'Dermatology', - 'Ophthalmology', 'ENT', 'Anesthesiology', 'Pathology', 'Psychiatry' -] - -# Saudi Medical License Formats SAUDI_LICENSE_PREFIXES = ['MOH', 'SCFHS', 'SMLE', 'SFH'] - -def generate_saudi_phone(): - """Generate Saudi phone number""" - area_codes = ['11', '12', '13', '14', '16', '17'] # Major Saudi area codes - return f"+966-{random.choice(area_codes)}-{random.randint(100, 999)}-{random.randint(1000, 9999)}" +ROLE_DISTRIBUTION = { + 'PHYSICIAN': 0.15, + 'NURSE': 0.25, + 'PHARMACIST': 0.08, + 'LAB_TECH': 0.10, + 'RAD_TECH': 0.08, + 'RADIOLOGIST': 0.05, + 'ADMIN': 0.07, + 'MEDICAL_ASSISTANT': 0.12, + 'CLERICAL': 0.10 +} -def generate_saudi_mobile(): - """Generate Saudi mobile number""" - mobile_prefixes = ['50', '53', '54', '55', '56', '57', '58', '59'] # Saudi mobile prefixes - return f"+966-{random.choice(mobile_prefixes)}-{random.randint(100, 999)}-{random.randint(1000, 9999)}" +# ------------------------------- +# Helpers +# ------------------------------- +def ensure_departments(tenant): + """ + Ensure Department objects exist for this tenant; return a list of them. + Adjust if your Department is global (then drop tenant filtering). + """ + existing = list(Department.objects.filter(tenant=tenant)) if 'tenant' in [f.name for f in Department._meta.fields] else list(Department.objects.all()) + if existing: + return existing + + # create seed departments + bulk = [] + for name in SAUDI_DEPARTMENTS: + if 'tenant' in [f.name for f in Department._meta.fields]: + bulk.append(Department(name=name, tenant=tenant)) + else: + bulk.append(Department(name=name)) + Department.objects.bulk_create(bulk, ignore_conflicts=True) + + return list(Department.objects.filter(tenant=tenant)) if 'tenant' in [f.name for f in Department._meta.fields] else list(Department.objects.all()) + + +def generate_saudi_mobile_e164(): + """Generate Saudi E.164 mobile: +9665XXXXXXXX""" + return f"+9665{random.randint(10000000, 99999999)}" def generate_saudi_license(): - """Generate Saudi medical license number""" + """Generate Saudi medical license number (fictional format)""" prefix = random.choice(SAUDI_LICENSE_PREFIXES) return f"{prefix}-{random.randint(100000, 999999)}" -def generate_saudi_employee_id(tenant_name, role): - """Generate Saudi employee ID""" - tenant_code = ''.join([c for c in tenant_name.upper() if c.isalpha()])[:3] - role_code = role[:3].upper() - return f"{tenant_code}-{role_code}-{random.randint(1000, 9999)}" +def tenant_scoped_unique_username(tenant, base_username: str) -> str: + """ + Make username unique within a tenant (your User has tenant-scoped unique constraint). + """ + username = base_username + i = 1 + while User.objects.filter(tenant=tenant, username=username).exists(): + i += 1 + username = f"{base_username}{i}" + return username +def pick_job_title(role: str) -> str: + titles = SAUDI_JOB_TITLES.get(role) + if titles: + return random.choice(titles) + # fallback + return role.replace('_', ' ').title() + + +# ------------------------------- +# Generators +# ------------------------------- def create_saudi_users(tenants, users_per_tenant=50): - """Create Saudi healthcare users""" - users = [] - - role_distribution = { - 'PHYSICIAN': 0.15, - 'NURSE': 0.25, - 'PHARMACIST': 0.08, - 'LAB_TECH': 0.10, - 'RAD_TECH': 0.08, - 'RADIOLOGIST': 0.05, - 'ADMIN': 0.07, - 'MEDICAL_ASSISTANT': 0.12, - 'CLERICAL': 0.10 - } + """ + Create Users (auth + security), then populate Employee profile. + Relies on the post_save signal to create Employee automatically. + """ + all_users = [] for tenant in tenants: + departments = ensure_departments(tenant) tenant_users = [] - for role, percentage in role_distribution.items(): - user_count = max(1, int(users_per_tenant * percentage)) + for role, pct in ROLE_DISTRIBUTION.items(): + count = max(1, int(users_per_tenant * pct)) - for i in range(user_count): - # Determine gender for Arabic naming + for _ in range(count): is_male = random.choice([True, False]) first_name = random.choice(SAUDI_FIRST_NAMES_MALE if is_male else SAUDI_FIRST_NAMES_FEMALE) last_name = random.choice(SAUDI_FAMILY_NAMES) - middle_name = random.choice(SAUDI_MIDDLE_NAMES) if random.choice([True, False]) else None - # Generate username - username = f"{first_name.lower()}.{last_name.lower().replace('-', '').replace('al', '')}" - counter = 1 - original_username = username - while User.objects.filter(username=username).exists(): - username = f"{original_username}{counter}" - counter += 1 - - # Generate email + # base username like "mohammed.alrashid" + base_username = f"{first_name.lower()}.{last_name.lower().replace('-', '').replace('al', '')}" + username = tenant_scoped_unique_username(tenant, base_username) email = f"{username}@{tenant.name.lower().replace(' ', '').replace('-', '')}.sa" - # Professional information - department = random.choice(SAUDI_DEPARTMENTS) - job_title = random.choice(SAUDI_JOB_TITLES.get(role, [f"{role.replace('_', ' ').title()}"])) - - # License information for medical professionals - license_number = None - license_state = None - license_expiry = None - npi_number = None - - if role in ['PHYSICIAN', 'NURSE', 'PHARMACIST', 'RADIOLOGIST']: - license_number = generate_saudi_license() - license_state = random.choice(SAUDI_PROVINCES) - license_expiry = django_timezone.now().date() + timedelta(days=random.randint(365, 1095)) - if role == 'PHYSICIAN': - npi_number = f"SA{random.randint(1000000, 9999999)}" + is_admin = role in ['ADMIN', 'SUPER_ADMIN'] + is_superuser = role == 'SUPER_ADMIN' + # Auth-level fields only user = User.objects.create( + tenant=tenant, username=username, email=email, first_name=first_name, last_name=last_name, - middle_name=middle_name, - preferred_name=first_name if random.choice([True, False]) else None, - tenant=tenant, + is_active=True, + is_staff=is_admin, + is_superuser=is_superuser, - # Contact information - phone_number=generate_saudi_phone(), - mobile_number=generate_saudi_mobile(), - - # Professional information - employee_id=generate_saudi_employee_id(tenant.name, role), - department=department, - job_title=job_title, - role=role, - - # License information - license_number=license_number, - license_state=license_state, - license_expiry=license_expiry, - npi_number=npi_number, - - # Security settings + # security/session (these live on User by design) force_password_change=random.choice([True, False]), password_expires_at=django_timezone.now() + timedelta(days=random.randint(90, 365)), failed_login_attempts=random.randint(0, 2), - two_factor_enabled=random.choice([True, False]) if role in ['PHYSICIAN', 'ADMIN', - 'PHARMACIST'] else False, - - # Session settings + two_factor_enabled=random.choice([True, False]) if role in ['PHYSICIAN', 'ADMIN', 'PHARMACIST'] else False, max_concurrent_sessions=random.choice([1, 2, 3, 5]), session_timeout_minutes=random.choice([30, 60, 120, 240]), - - # Preferences - user_timezone='Asia/Riyadh', - language=random.choice(['ar', 'en', 'ar_SA']), - theme=random.choice(['LIGHT', 'DARK', 'AUTO']), - - # Status - is_verified=True, - is_approved=True, - approval_date=django_timezone.now() - timedelta(days=random.randint(1, 180)), - is_active=True, - is_staff=role in ['ADMIN', 'SUPER_ADMIN'], - is_superuser=role == 'SUPER_ADMIN', - - # Metadata - created_at=django_timezone.now() - timedelta(days=random.randint(1, 365)), - updated_at=django_timezone.now() - timedelta(days=random.randint(0, 30)), last_password_change=django_timezone.now() - timedelta(days=random.randint(1, 90)), - date_joined=django_timezone.now() - timedelta(days=random.randint(1, 365)) + date_joined=django_timezone.now() - timedelta(days=random.randint(1, 365)), ) - - # Set password - user.set_password('Hospital@123') # Default password + user.set_password('Hospital@123') user.save() - users.append(user) - tenant_users.append(user) + # Signal should have created Employee; now populate Employee fields + emp: Employee = user.employee_profile # created by signal + emp.tenant = tenant # ensure alignment + emp.first_name = first_name + emp.last_name = last_name + emp.preferred_name = first_name if random.choice([True, False]) else None - # Set approval relationships - admin_users = [u for u in tenant_users if u.role in ['ADMIN', 'SUPER_ADMIN']] + # Contact (E.164 KSA) + mobile = generate_saudi_mobile_e164() + emp.phone = mobile + emp.mobile_phone = mobile + emp.email = email + + # Role/Org + emp.role = role + emp.department = random.choice(departments) if departments else None + emp.job_title = pick_job_title(role) + + # License (only some roles) + if role in ['PHYSICIAN', 'NURSE', 'PHARMACIST', 'RADIOLOGIST']: + emp.license_number = generate_saudi_license() + emp.license_state = random.choice(SAUDI_PROVINCES) + emp.license_expiry_date = django_timezone.now().date() + timedelta(days=random.randint(365, 1095)) + if role == 'PHYSICIAN': + # fictitious local analogue to NPI + emp.npi_number = f"SA{random.randint(1000000, 9999999)}" + + # Preferences + emp.user_timezone = 'Asia/Riyadh' + emp.language = random.choice(['ar', 'en', 'ar_SA']) + emp.theme = random.choice([Employee.Theme.LIGHT, Employee.Theme.DARK, Employee.Theme.AUTO]) + + # Status / approval (approved later per-tenant) + emp.is_verified = True + emp.is_approved = True + emp.approval_date = django_timezone.now() - timedelta(days=random.randint(1, 180)) + + emp.save() + + tenant_users.append(user) + all_users.append(user) + + # Approval relationships: choose an approver among admins in this tenant + admin_users = [u for u in tenant_users if u.is_staff or u.is_superuser] if admin_users: approver = random.choice(admin_users) - for user in tenant_users: - if user != approver and user.role != 'SUPER_ADMIN': - user.approved_by = approver - user.save() + for u in tenant_users: + if u != approver: + emp = u.employee_profile + emp.approved_by = approver + emp.save(update_fields=['approved_by']) print(f"Created {len(tenant_users)} users for {tenant.name}") - return users + return all_users def create_saudi_two_factor_devices(users): @@ -249,8 +266,8 @@ def create_saudi_two_factor_devices(users): for user in users: if user.two_factor_enabled: - # Create 1-3 devices per user device_count = random.randint(1, 3) + emp = getattr(user, 'employee_profile', None) for _ in range(device_count): device_type = random.choice(device_types) @@ -271,9 +288,9 @@ def create_saudi_two_factor_devices(users): if device_type == 'TOTP': device_data['secret_key'] = secrets.token_urlsafe(32) elif device_type == 'SMS': - device_data['phone_number'] = user.mobile_number + device_data['phone_number'] = emp.mobile_phone if emp else None elif device_type == 'EMAIL': - device_data['email_address'] = user.email + device_data['email_address'] = emp.email if emp and emp.email else user.email device = TwoFactorDevice.objects.create(**device_data) devices.append(device) @@ -285,21 +302,19 @@ def create_saudi_two_factor_devices(users): def create_saudi_social_accounts(users): """Create social authentication accounts for Saudi users""" social_accounts = [] - - # Common providers in Saudi Arabia providers = ['GOOGLE', 'MICROSOFT', 'APPLE', 'LINKEDIN'] for user in users: - # 30% chance of having social accounts - if random.choice([True, False, False, False]): + if random.choice([True, False, False, False]): # ~25% chance provider = random.choice(providers) + display_name = user.get_full_name() or (user.employee_profile.get_display_name() if hasattr(user, 'employee_profile') else user.username) social_account = SocialAccount.objects.create( user=user, provider=provider, provider_id=f"{provider.lower()}_{random.randint(100000000, 999999999)}", provider_email=user.email, - display_name=user.get_full_name(), + display_name=display_name, profile_url=f"https://{provider.lower()}.com/profile/{user.username}", avatar_url=f"https://{provider.lower()}.com/avatar/{user.username}.jpg", access_token=secrets.token_urlsafe(64), @@ -323,30 +338,27 @@ def create_saudi_user_sessions(users): '37.99.', '37.200.', '31.9.', '31.173.', '188.161.', '185.84.', '188.245.', '217.9.', '82.205.', '5.63.' ] - browsers = [ 'Chrome 120.0.0.0', 'Safari 17.1.2', 'Firefox 121.0.0', 'Edge 120.0.0.0', 'Chrome Mobile 120.0.0.0', 'Safari Mobile 17.1.2' ] - operating_systems = [ 'Windows 11', 'Windows 10', 'macOS 14.0', 'iOS 17.1.2', 'Android 14', 'Ubuntu 22.04' ] - device_types = ['DESKTOP', 'MOBILE', 'TABLET'] login_methods = ['PASSWORD', 'TWO_FACTOR', 'SOCIAL', 'SSO'] for user in users: - # Create 1-5 sessions per user session_count = random.randint(1, 5) + timeout_minutes = user.session_timeout_minutes or 30 for i in range(session_count): ip_prefix = random.choice(saudi_ips) ip_address = f"{ip_prefix}{random.randint(1, 255)}.{random.randint(1, 255)}" session_start = django_timezone.now() - timedelta(hours=random.randint(1, 720)) - is_active = i == 0 and random.choice([True, True, False]) # Most recent session likely active + is_active = (i == 0) and random.choice([True, True, False]) # recent likely active session = UserSession.objects.create( user=user, @@ -364,7 +376,7 @@ def create_saudi_user_sessions(users): login_method=random.choice(login_methods), created_at=session_start, last_activity_at=session_start + timedelta(minutes=random.randint(1, 480)), - expires_at=session_start + timedelta(hours=user.session_timeout_minutes // 60), + expires_at=session_start + timedelta(minutes=timeout_minutes), ended_at=None if is_active else session_start + timedelta(hours=random.randint(1, 8)) ) sessions.append(session) @@ -376,16 +388,12 @@ def create_saudi_user_sessions(users): def create_saudi_password_history(users): """Create password history for Saudi users""" password_history = [] - passwords = ['Hospital@123', 'Medical@456', 'Health@789', 'Saudi@2024', 'Secure@Pass'] for user in users: - # Create 1-5 password history entries per user history_count = random.randint(1, 5) - for i in range(history_count): password = random.choice(passwords) - history_entry = PasswordHistory.objects.create( user=user, password_hash=make_password(password), @@ -397,33 +405,29 @@ def create_saudi_password_history(users): return password_history +# ------------------------------- +# Main +# ------------------------------- def main(): - """Main function to generate all Saudi accounts data""" print("Starting Saudi Healthcare Accounts Data Generation...") - # Get existing tenants tenants = list(Tenant.objects.all()) if not tenants: - print("❌ No tenants found. Please run the core data generator first.") + print("❌ No tenants found. Please seed core tenants first.") return - # Create users - print("\n1. Creating Saudi Healthcare Users...") - users = create_saudi_users(tenants, 40) # 40 users per tenant + print("\n1. Creating Saudi Healthcare Users (with Employee profiles)...") + users = create_saudi_users(tenants, users_per_tenant=40) - # Create two-factor devices print("\n2. Creating Two-Factor Authentication Devices...") devices = create_saudi_two_factor_devices(users) - # Create social accounts print("\n3. Creating Social Authentication Accounts...") social_accounts = create_saudi_social_accounts(users) - # Create user sessions print("\n4. Creating User Sessions...") sessions = create_saudi_user_sessions(users) - # Create password history print("\n5. Creating Password History...") password_history = create_saudi_password_history(users) @@ -435,10 +439,10 @@ def main(): print(f" - User Sessions: {len(sessions)}") print(f" - Password History Entries: {len(password_history)}") - # Role distribution summary role_counts = {} - for user in users: - role_counts[user.role] = role_counts.get(user.role, 0) + 1 + for u in users: + role = u.employee_profile.role if hasattr(u, 'employee_profile') else 'UNKNOWN' + role_counts[role] = role_counts.get(role, 0) + 1 print(f"\n👥 User Role Distribution:") for role, count in sorted(role_counts.items()): @@ -449,7 +453,7 @@ def main(): 'devices': devices, 'social_accounts': social_accounts, 'sessions': sessions, - 'password_history': password_history + 'password_history': password_history, } diff --git a/analytics/migrations/0001_initial.py b/analytics/migrations/0001_initial.py index f2108876..863bd912 100644 --- a/analytics/migrations/0001_initial.py +++ b/analytics/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-09-08 07:28 +# Generated by Django 5.2.6 on 2025-09-15 14:05 import django.core.validators import django.db.models.deletion diff --git a/analytics/migrations/0002_initial.py b/analytics/migrations/0002_initial.py index 6c2a4937..edcdc9f1 100644 --- a/analytics/migrations/0002_initial.py +++ b/analytics/migrations/0002_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-09-08 07:28 +# Generated by Django 5.2.6 on 2025-09-15 14:05 import django.db.models.deletion from django.conf import settings diff --git a/analytics/migrations/__pycache__/0001_initial.cpython-312.pyc b/analytics/migrations/__pycache__/0001_initial.cpython-312.pyc index fb85ad29..2512cb16 100644 Binary files a/analytics/migrations/__pycache__/0001_initial.cpython-312.pyc and b/analytics/migrations/__pycache__/0001_initial.cpython-312.pyc differ diff --git a/analytics/migrations/__pycache__/0002_initial.cpython-312.pyc b/analytics/migrations/__pycache__/0002_initial.cpython-312.pyc index 9c0b3940..2e99d93b 100644 Binary files a/analytics/migrations/__pycache__/0002_initial.cpython-312.pyc and b/analytics/migrations/__pycache__/0002_initial.cpython-312.pyc differ diff --git a/analytics/templates/analytics/calculate_metric.html b/analytics/templates/analytics/calculate_metric.html index e09e986c..4329baf8 100644 --- a/analytics/templates/analytics/calculate_metric.html +++ b/analytics/templates/analytics/calculate_metric.html @@ -8,220 +8,219 @@ {% endblock %} {% block content %} -
-
-
-
-
-
- - -

Calculate Metric

- -
-
-

Metric Calculation

-
-
- {% if messages %} - {% for message in messages %} - - {% endfor %} - {% endif %} - -
- {% csrf_token %} - -
- -
- -
-
- -
- -
- - Start Date -
-
- - End Date -
-
- -
- -
-
-
- -
-
- -
-
-
-
- -
- -
-
- - -
-
- - -
-
-
- -
-
- - Cancel -
-
-
-
+ +
+
+
+
+
+ + +

Calculate Metric

+ +
+
+

Metric Calculation

- - {% if calculation_result %} -
-
-

Calculation Results

-
-
+
+ {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + +
+ {% csrf_token %} + +
+ +
+ +
+
+ +
+ +
+ + Start Date +
+
+ + End Date +
+
+ +
+ +
+
+
+ +
+
+ +
+
+
+
+ +
+ +
+
+ + +
+
+ + +
+
+
+
-
-
-
-
-
-
{{ calculation_result.value }}
-
{{ calculation_result.metric_name }}
-
-
- -
-
-
-
-
-
-
-
-
-
Unit:
-
{{ calculation_result.unit }}
-
-
-
Period:
-
{{ calculation_result.period }}
-
-
-
Calculated:
-
{{ calculation_result.calculated_at }}
-
-
-
Status:
-
- {% if calculation_result.status == 'success' %} - Success - {% else %} - {{ calculation_result.status|title }} - {% endif %} -
-
-
-
+
+ + Cancel
- - {% if calculation_result.breakdown %} -
-
Calculation Breakdown:
-
- - - - - - - - - - - {% for item in calculation_result.breakdown %} - - - - - - - {% endfor %} - -
ComponentValueWeightContribution
{{ item.component }}{{ item.value }}{{ item.weight }}%{{ item.contribution }}
-
-
- {% endif %} - - {% if calculation_result.notes %} -
-
Notes:
-

{{ calculation_result.notes }}

-
- {% endif %} -
+
- {% endif %}
- -
-
-
-

Quick Actions

-
-
-
- - All Metrics - - - Create Metric - + + {% if calculation_result %} +
+
+

Calculation Results

+
+
+
+
+
+
+
+
+
{{ calculation_result.value }}
+
{{ calculation_result.metric_name }}
+
+
+ +
+
+
+
+
+
+
+
+
Unit:
+
{{ calculation_result.unit }}
+
+
+
Period:
+
{{ calculation_result.period }}
+
+
+
Calculated:
+
{{ calculation_result.calculated_at }}
+
+
+
Status:
+
+ {% if calculation_result.status == 'success' %} + Success + {% else %} + {{ calculation_result.status|title }} + {% endif %} +
+
+
+
+
+
+ + {% if calculation_result.breakdown %} +
+
Calculation Breakdown:
+
+ + + + + + + + + + + {% for item in calculation_result.breakdown %} + + + + + + + {% endfor %} + +
ComponentValueWeightContribution
{{ item.component }}{{ item.value }}{{ item.weight }}%{{ item.contribution }}
+
+
+ {% endif %} + + {% if calculation_result.notes %} +
+
Notes:
+

{{ calculation_result.notes }}

+
+ {% endif %} +
+
+ {% endif %} +
+ +
+
+
+

Quick Actions

+
+
@@ -230,6 +229,7 @@
+ {% endblock %} {% block js %} diff --git a/analytics/templates/analytics/execute_report.html b/analytics/templates/analytics/execute_report.html index 291a15cd..9def1b9b 100644 --- a/analytics/templates/analytics/execute_report.html +++ b/analytics/templates/analytics/execute_report.html @@ -8,177 +8,176 @@ {% endblock %} {% block content %} -
-
-
-
-
-
- - -

Execute Report

- -
-
-

Report Execution

-
-
- {% if messages %} - {% for message in messages %} - - {% endfor %} - {% endif %} - -
- {% csrf_token %} - -
- -
- -
-
- -
- -
- - Start Date -
-
- - End Date -
-
- -
- -
- -
-
- -
- -
- - Optional parameters in JSON format -
-
- -
-
- - Cancel -
-
-
-
+ +
+
+
+
+
+ + +

Execute Report

+ +
+
+

Report Execution

- - {% if execution_result %} -
-
-

Execution Results

-
-
+
+ {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + +
+ {% csrf_token %} +
-
Status:
+
- {% if execution_result.success %} - Completed - {% else %} - Failed - {% endif %} +
- +
-
Execution Time:
-
{{ execution_result.execution_time }}s
-
- -
-
Records:
-
{{ execution_result.record_count|default:"N/A" }}
-
- - {% if execution_result.download_url %} -
-
Download:
-
- - Download Report - + +
+ + Start Date +
+
+ + End Date
- {% endif %} - - {% if execution_result.preview_data %} + +
+ +
+ +
+
+ +
+ +
+ + Optional parameters in JSON format +
+
+
-
-
Preview Data:
-
- - +
+ + Cancel +
+ + + + + + {% if execution_result %} +
+
+

Execution Results

+
+
+
+
Status:
+
+ {% if execution_result.success %} + Completed + {% else %} + Failed + {% endif %} +
+
+ +
+
Execution Time:
+
{{ execution_result.execution_time }}s
+
+ +
+
Records:
+
{{ execution_result.record_count|default:"N/A" }}
+
+ + {% if execution_result.download_url %} +
+
Download:
+ +
+ {% endif %} + + {% if execution_result.preview_data %} +
+
+
Preview Data:
+
+
+ + + {% for header in execution_result.headers %} + + {% endfor %} + + + + {% for row in execution_result.preview_data %} - {% for header in execution_result.headers %} - + {% for cell in row %} + {% endfor %} - - - {% for row in execution_result.preview_data %} - - {% for cell in row %} - - {% endfor %} - - {% endfor %} - -
{{ header }}
{{ header }}{{ cell }}
{{ cell }}
-
+ {% endfor %} + +
- {% endif %}
+ {% endif %}
- {% endif %}
- -
-
-
-

Quick Actions

-
- + +
+
+
+

Quick Actions

+
+
@@ -187,6 +186,7 @@
+ {% endblock %} {% block js %} diff --git a/analytics/templates/analytics/test_data_source.html b/analytics/templates/analytics/test_data_source.html index f56b6b07..c0131dc5 100644 --- a/analytics/templates/analytics/test_data_source.html +++ b/analytics/templates/analytics/test_data_source.html @@ -8,126 +8,125 @@ {% endblock %} {% block content %} -
-
-
-
-
-
- - -

Test Data Source Connection

- -
-
-

Connection Test

-
-
- {% if messages %} - {% for message in messages %} - - {% endfor %} - {% endif %} - -
- {% csrf_token %} - -
- -
- -
-
- -
- -
- - Leave empty to test basic connection -
-
- -
-
- - Cancel -
-
-
-
+ +
+
+
+
+
+ + +

Test Data Source Connection

+ +
+
+

Connection Test

- - {% if test_result %} -
-
-

Test Results

-
-
+
+ {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + +
+ {% csrf_token %} +
-
Status:
+
- {% if test_result.success %} - Connected - {% else %} - Failed - {% endif %} +
- +
-
Response Time:
-
{{ test_result.response_time }}ms
+ +
+ + Leave empty to test basic connection +
- - {% if test_result.message %} -
-
Message:
-
{{ test_result.message }}
-
- {% endif %} - - {% if test_result.data %} +
-
Sample Data:
-
-
{{ test_result.data|truncatechars:500 }}
+
+ + Cancel
- {% endif %} -
+
- {% endif %}
- -
-
-
-

Quick Actions

-
-
-
- - All Data Sources - - - Add Data Source - + + {% if test_result %} +
+
+

Test Results

+
+
+
+
Status:
+
+ {% if test_result.success %} + Connected + {% else %} + Failed + {% endif %}
+ +
+
Response Time:
+
{{ test_result.response_time }}ms
+
+ + {% if test_result.message %} +
+
Message:
+
{{ test_result.message }}
+
+ {% endif %} + + {% if test_result.data %} +
+
Sample Data:
+
+
{{ test_result.data|truncatechars:500 }}
+
+
+ {% endif %} +
+
+ {% endif %} +
+ +
+
+
+

Quick Actions

+
+
@@ -135,6 +134,7 @@
+ {% endblock %} {% block js %} diff --git a/analytics_data.py b/analytics_data.py index a2ef91a3..f6eabb4c 100644 --- a/analytics_data.py +++ b/analytics_data.py @@ -56,7 +56,7 @@ def generate_dashboards(tenants, users): created_by=creator ) dashboard.allowed_users.add(creator) - dashboard.allowed_roles = [creator.role] + dashboard.allowed_roles = [creator.employee_profile.role] dashboard.save() dashboards.append(dashboard) print(f"✅ Created {len(dashboards)} dashboards") diff --git a/appointments/__pycache__/forms.cpython-312.pyc b/appointments/__pycache__/forms.cpython-312.pyc index 0b8962c8..400cf857 100644 Binary files a/appointments/__pycache__/forms.cpython-312.pyc and b/appointments/__pycache__/forms.cpython-312.pyc differ diff --git a/appointments/__pycache__/models.cpython-312.pyc b/appointments/__pycache__/models.cpython-312.pyc index d253311d..f205c1a8 100644 Binary files a/appointments/__pycache__/models.cpython-312.pyc and b/appointments/__pycache__/models.cpython-312.pyc differ diff --git a/appointments/__pycache__/urls.cpython-312.pyc b/appointments/__pycache__/urls.cpython-312.pyc index a08b1834..e6d4a5ca 100644 Binary files a/appointments/__pycache__/urls.cpython-312.pyc and b/appointments/__pycache__/urls.cpython-312.pyc differ diff --git a/appointments/__pycache__/views.cpython-312.pyc b/appointments/__pycache__/views.cpython-312.pyc index 0c64ffa1..41f6326c 100644 Binary files a/appointments/__pycache__/views.cpython-312.pyc and b/appointments/__pycache__/views.cpython-312.pyc differ diff --git a/appointments/api/__pycache__/__init__.cpython-312.pyc b/appointments/api/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..c5a64f98 Binary files /dev/null and b/appointments/api/__pycache__/__init__.cpython-312.pyc differ diff --git a/appointments/api/__pycache__/serializers.cpython-312.pyc b/appointments/api/__pycache__/serializers.cpython-312.pyc new file mode 100644 index 00000000..5980ff65 Binary files /dev/null and b/appointments/api/__pycache__/serializers.cpython-312.pyc differ diff --git a/appointments/api/__pycache__/urls.cpython-312.pyc b/appointments/api/__pycache__/urls.cpython-312.pyc new file mode 100644 index 00000000..854e9104 Binary files /dev/null and b/appointments/api/__pycache__/urls.cpython-312.pyc differ diff --git a/appointments/api/__pycache__/views.cpython-312.pyc b/appointments/api/__pycache__/views.cpython-312.pyc new file mode 100644 index 00000000..70bf1a35 Binary files /dev/null and b/appointments/api/__pycache__/views.cpython-312.pyc differ diff --git a/appointments/forms.py b/appointments/forms.py index dc0514ee..cbf2e79d 100644 --- a/appointments/forms.py +++ b/appointments/forms.py @@ -353,7 +353,7 @@ class AppointmentSearchForm(forms.Form): self.fields['provider'].queryset = User.objects.filter( tenant=user.tenant, is_active=True, - role__in=['PHYSICIAN', 'NURSE', 'NURSE_PRACTITIONER', 'PHYSICIAN_ASSISTANT'] + employee_profile__role__in=['PHYSICIAN', 'NURSE', 'NURSE_PRACTITIONER', 'PHYSICIAN_ASSISTANT'] ).order_by('last_name', 'first_name') @@ -645,34 +645,34 @@ class WaitingListContactLogForm(forms.ModelForm): widgets = { 'contact_method': forms.Select(attrs={ - 'class': 'form-select', + 'class': 'form-select form-select-sm', 'required': True }), 'contact_outcome': forms.Select(attrs={ - 'class': 'form-select', + 'class': 'form-select form-select-sm', 'required': True }), 'appointment_offered': forms.CheckboxInput(attrs={ 'class': 'form-check-input' }), 'offered_date': forms.DateInput(attrs={ - 'class': 'form-control', + 'class': 'form-control form-control-sm', 'type': 'date' }), 'offered_time': forms.TimeInput(attrs={ - 'class': 'form-control', + 'class': 'form-control form-control-sm', 'type': 'time' }), 'patient_response': forms.Select(attrs={ - 'class': 'form-select' + 'class': 'form-select form-select-sm' }), 'notes': forms.Textarea(attrs={ - 'class': 'form-control', + 'class': 'form-control form-control-sm', 'rows': 4, 'placeholder': 'Notes from contact attempt...' }), 'next_contact_date': forms.DateInput(attrs={ - 'class': 'form-control', + 'class': 'form-control form-control-sm', 'type': 'date', 'min': date.today().isoformat() }), diff --git a/appointments/migrations/0001_initial.py b/appointments/migrations/0001_initial.py index bfb983fd..44f2b65a 100644 --- a/appointments/migrations/0001_initial.py +++ b/appointments/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-09-08 07:28 +# Generated by Django 5.2.6 on 2025-09-15 14:05 import django.core.validators import django.db.models.deletion @@ -549,6 +549,526 @@ class Migration(migrations.Migration): "ordering": ["-scheduled_start"], }, ), + migrations.CreateModel( + name="WaitingList", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "waiting_list_id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + help_text="Unique waiting list entry identifier", + unique=True, + ), + ), + ( + "appointment_type", + models.CharField( + choices=[ + ("CONSULTATION", "Consultation"), + ("FOLLOW_UP", "Follow-up"), + ("PROCEDURE", "Procedure"), + ("SURGERY", "Surgery"), + ("DIAGNOSTIC", "Diagnostic"), + ("THERAPY", "Therapy"), + ("VACCINATION", "Vaccination"), + ("SCREENING", "Screening"), + ("EMERGENCY", "Emergency"), + ("TELEMEDICINE", "Telemedicine"), + ("OTHER", "Other"), + ], + help_text="Type of appointment requested", + max_length=50, + ), + ), + ( + "specialty", + models.CharField( + choices=[ + ("FAMILY_MEDICINE", "Family Medicine"), + ("INTERNAL_MEDICINE", "Internal Medicine"), + ("PEDIATRICS", "Pediatrics"), + ("CARDIOLOGY", "Cardiology"), + ("DERMATOLOGY", "Dermatology"), + ("ENDOCRINOLOGY", "Endocrinology"), + ("GASTROENTEROLOGY", "Gastroenterology"), + ("NEUROLOGY", "Neurology"), + ("ONCOLOGY", "Oncology"), + ("ORTHOPEDICS", "Orthopedics"), + ("PSYCHIATRY", "Psychiatry"), + ("RADIOLOGY", "Radiology"), + ("SURGERY", "Surgery"), + ("UROLOGY", "Urology"), + ("GYNECOLOGY", "Gynecology"), + ("OPHTHALMOLOGY", "Ophthalmology"), + ("ENT", "Ear, Nose & Throat"), + ("EMERGENCY", "Emergency Medicine"), + ("OTHER", "Other"), + ], + help_text="Medical specialty required", + max_length=100, + ), + ), + ( + "priority", + models.CharField( + choices=[ + ("ROUTINE", "Routine"), + ("URGENT", "Urgent"), + ("STAT", "STAT"), + ("EMERGENCY", "Emergency"), + ], + default="ROUTINE", + help_text="Clinical priority level", + max_length=20, + ), + ), + ( + "urgency_score", + models.PositiveIntegerField( + default=1, + help_text="Clinical urgency score (1-10, 10 being most urgent)", + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(10), + ], + ), + ), + ( + "clinical_indication", + models.TextField( + help_text="Clinical reason for appointment request" + ), + ), + ( + "diagnosis_codes", + models.JSONField( + blank=True, default=list, help_text="ICD-10 diagnosis codes" + ), + ), + ( + "preferred_date", + models.DateField( + blank=True, + help_text="Patient preferred appointment date", + null=True, + ), + ), + ( + "preferred_time", + models.TimeField( + blank=True, + help_text="Patient preferred appointment time", + null=True, + ), + ), + ( + "flexible_scheduling", + models.BooleanField( + default=True, + help_text="Patient accepts alternative dates/times", + ), + ), + ( + "earliest_acceptable_date", + models.DateField( + blank=True, + help_text="Earliest acceptable appointment date", + null=True, + ), + ), + ( + "latest_acceptable_date", + models.DateField( + blank=True, + help_text="Latest acceptable appointment date", + null=True, + ), + ), + ( + "acceptable_days", + models.JSONField( + blank=True, + default=list, + help_text="Acceptable days of week (0=Monday, 6=Sunday)", + ), + ), + ( + "acceptable_times", + models.JSONField( + blank=True, default=list, help_text="Acceptable time ranges" + ), + ), + ( + "contact_method", + models.CharField( + choices=[ + ("PHONE", "Phone"), + ("EMAIL", "Email"), + ("SMS", "SMS"), + ("PORTAL", "Patient Portal"), + ("MAIL", "Mail"), + ], + default="PHONE", + help_text="Preferred contact method", + max_length=20, + ), + ), + ( + "contact_phone", + models.CharField( + blank=True, + help_text="Contact phone number", + max_length=20, + null=True, + ), + ), + ( + "contact_email", + models.EmailField( + blank=True, + help_text="Contact email address", + max_length=254, + null=True, + ), + ), + ( + "status", + models.CharField( + choices=[ + ("ACTIVE", "Active"), + ("CONTACTED", "Contacted"), + ("OFFERED", "Appointment Offered"), + ("SCHEDULED", "Scheduled"), + ("CANCELLED", "Cancelled"), + ("EXPIRED", "Expired"), + ("TRANSFERRED", "Transferred"), + ], + default="ACTIVE", + help_text="Waiting list status", + max_length=20, + ), + ), + ( + "position", + models.PositiveIntegerField( + blank=True, + help_text="Position in waiting list queue", + null=True, + ), + ), + ( + "estimated_wait_time", + models.PositiveIntegerField( + blank=True, help_text="Estimated wait time in days", null=True + ), + ), + ( + "last_contacted", + models.DateTimeField( + blank=True, + help_text="Last contact attempt date/time", + null=True, + ), + ), + ( + "contact_attempts", + models.PositiveIntegerField( + default=0, help_text="Number of contact attempts made" + ), + ), + ( + "max_contact_attempts", + models.PositiveIntegerField( + default=3, help_text="Maximum contact attempts before expiring" + ), + ), + ( + "appointments_offered", + models.PositiveIntegerField( + default=0, help_text="Number of appointments offered" + ), + ), + ( + "appointments_declined", + models.PositiveIntegerField( + default=0, help_text="Number of appointments declined" + ), + ), + ( + "last_offer_date", + models.DateTimeField( + blank=True, + help_text="Date of last appointment offer", + null=True, + ), + ), + ( + "requires_interpreter", + models.BooleanField( + default=False, help_text="Patient requires interpreter services" + ), + ), + ( + "interpreter_language", + models.CharField( + blank=True, + help_text="Required interpreter language", + max_length=50, + null=True, + ), + ), + ( + "accessibility_requirements", + models.TextField( + blank=True, + help_text="Special accessibility requirements", + null=True, + ), + ), + ( + "transportation_needed", + models.BooleanField( + default=False, + help_text="Patient needs transportation assistance", + ), + ), + ( + "insurance_verified", + models.BooleanField( + default=False, help_text="Insurance coverage verified" + ), + ), + ( + "authorization_required", + models.BooleanField( + default=False, help_text="Prior authorization required" + ), + ), + ( + "authorization_status", + models.CharField( + choices=[ + ("NOT_REQUIRED", "Not Required"), + ("PENDING", "Pending"), + ("APPROVED", "Approved"), + ("DENIED", "Denied"), + ("EXPIRED", "Expired"), + ], + default="NOT_REQUIRED", + help_text="Authorization status", + max_length=20, + ), + ), + ( + "authorization_number", + models.CharField( + blank=True, + help_text="Authorization number", + max_length=100, + null=True, + ), + ), + ( + "referring_provider", + models.CharField( + blank=True, + help_text="Referring provider name", + max_length=200, + null=True, + ), + ), + ( + "referral_date", + models.DateField( + blank=True, help_text="Date of referral", null=True + ), + ), + ( + "referral_urgency", + models.CharField( + choices=[ + ("ROUTINE", "Routine"), + ("URGENT", "Urgent"), + ("STAT", "STAT"), + ], + default="ROUTINE", + help_text="Referral urgency level", + max_length=20, + ), + ), + ( + "removal_reason", + models.CharField( + blank=True, + choices=[ + ("SCHEDULED", "Appointment Scheduled"), + ("PATIENT_CANCELLED", "Patient Cancelled"), + ("PROVIDER_CANCELLED", "Provider Cancelled"), + ("NO_RESPONSE", "No Response to Contact"), + ("INSURANCE_ISSUE", "Insurance Issue"), + ("TRANSFERRED", "Transferred to Another Provider"), + ("EXPIRED", "Entry Expired"), + ("DUPLICATE", "Duplicate Entry"), + ("OTHER", "Other"), + ], + help_text="Reason for removal from waiting list", + max_length=50, + null=True, + ), + ), + ( + "removal_notes", + models.TextField( + blank=True, + help_text="Additional notes about removal", + null=True, + ), + ), + ( + "removed_at", + models.DateTimeField( + blank=True, + help_text="Date/time removed from waiting list", + null=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "notes", + models.TextField( + blank=True, help_text="Additional notes and comments", null=True + ), + ), + ], + options={ + "verbose_name": "Waiting List Entry", + "verbose_name_plural": "Waiting List Entries", + "db_table": "appointments_waiting_list", + "ordering": ["priority", "urgency_score", "created_at"], + }, + ), + migrations.CreateModel( + name="WaitingListContactLog", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "contact_date", + models.DateTimeField( + auto_now_add=True, help_text="Date and time of contact attempt" + ), + ), + ( + "contact_method", + models.CharField( + choices=[ + ("PHONE", "Phone Call"), + ("EMAIL", "Email"), + ("SMS", "SMS"), + ("PORTAL", "Patient Portal Message"), + ("MAIL", "Mail"), + ("IN_PERSON", "In Person"), + ], + help_text="Method of contact used", + max_length=20, + ), + ), + ( + "contact_outcome", + models.CharField( + choices=[ + ("SUCCESSFUL", "Successful Contact"), + ("NO_ANSWER", "No Answer"), + ("BUSY", "Line Busy"), + ("VOICEMAIL", "Left Voicemail"), + ("EMAIL_SENT", "Email Sent"), + ("EMAIL_BOUNCED", "Email Bounced"), + ("SMS_SENT", "SMS Sent"), + ("SMS_FAILED", "SMS Failed"), + ("WRONG_NUMBER", "Wrong Number"), + ("DECLINED", "Patient Declined"), + ], + help_text="Outcome of contact attempt", + max_length=20, + ), + ), + ( + "appointment_offered", + models.BooleanField( + default=False, + help_text="Appointment was offered during contact", + ), + ), + ( + "offered_date", + models.DateField( + blank=True, help_text="Date of offered appointment", null=True + ), + ), + ( + "offered_time", + models.TimeField( + blank=True, help_text="Time of offered appointment", null=True + ), + ), + ( + "patient_response", + models.CharField( + blank=True, + choices=[ + ("ACCEPTED", "Accepted Appointment"), + ("DECLINED", "Declined Appointment"), + ("REQUESTED_DIFFERENT", "Requested Different Time"), + ("WILL_CALL_BACK", "Will Call Back"), + ("NO_LONGER_NEEDED", "No Longer Needed"), + ("INSURANCE_ISSUE", "Insurance Issue"), + ("NO_RESPONSE", "No Response"), + ], + help_text="Patient response to contact", + max_length=20, + null=True, + ), + ), + ( + "notes", + models.TextField( + blank=True, help_text="Notes from contact attempt", null=True + ), + ), + ( + "next_contact_date", + models.DateField( + blank=True, + help_text="Scheduled date for next contact attempt", + null=True, + ), + ), + ], + options={ + "verbose_name": "Waiting List Contact Log", + "verbose_name_plural": "Waiting List Contact Logs", + "db_table": "appointments_waiting_list_contact_log", + "ordering": ["-contact_date"], + }, + ), migrations.CreateModel( name="WaitingQueue", fields=[ diff --git a/appointments/migrations/0002_initial.py b/appointments/migrations/0002_initial.py index cc81bb00..5cdfc5c7 100644 --- a/appointments/migrations/0002_initial.py +++ b/appointments/migrations/0002_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-09-08 07:28 +# Generated by Django 5.2.6 on 2025-09-15 14:05 import django.db.models.deletion from django.conf import settings @@ -12,6 +12,7 @@ class Migration(migrations.Migration): dependencies = [ ("appointments", "0001_initial"), ("core", "0001_initial"), + ("hr", "0001_initial"), ("patients", "0001_initial"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -179,6 +180,105 @@ class Migration(migrations.Migration): to=settings.AUTH_USER_MODEL, ), ), + migrations.AddField( + model_name="waitinglist", + name="created_by", + field=models.ForeignKey( + blank=True, + help_text="User who created the waiting list entry", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="created_waiting_list_entries", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="waitinglist", + name="department", + field=models.ForeignKey( + help_text="Department for appointment", + on_delete=django.db.models.deletion.CASCADE, + related_name="waiting_list_entries", + to="hr.department", + ), + ), + migrations.AddField( + model_name="waitinglist", + name="patient", + field=models.ForeignKey( + help_text="Patient on waiting list", + on_delete=django.db.models.deletion.CASCADE, + related_name="waiting_list_entries", + to="patients.patientprofile", + ), + ), + migrations.AddField( + model_name="waitinglist", + name="provider", + field=models.ForeignKey( + blank=True, + help_text="Preferred healthcare provider", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="provider_waiting_list", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="waitinglist", + name="removed_by", + field=models.ForeignKey( + blank=True, + help_text="User who removed entry from waiting list", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="removed_waiting_list_entries", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="waitinglist", + name="scheduled_appointment", + field=models.ForeignKey( + blank=True, + help_text="Scheduled appointment from waiting list", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="waiting_list_entry", + to="appointments.appointmentrequest", + ), + ), + migrations.AddField( + model_name="waitinglist", + name="tenant", + field=models.ForeignKey( + help_text="Organization tenant", + on_delete=django.db.models.deletion.CASCADE, + related_name="waiting_list_entries", + to="core.tenant", + ), + ), + migrations.AddField( + model_name="waitinglistcontactlog", + name="contacted_by", + field=models.ForeignKey( + blank=True, + help_text="Staff member who made contact", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="waitinglistcontactlog", + name="waiting_list_entry", + field=models.ForeignKey( + help_text="Associated waiting list entry", + on_delete=django.db.models.deletion.CASCADE, + related_name="contact_logs", + to="appointments.waitinglist", + ), + ), migrations.AddField( model_name="waitingqueue", name="created_by", @@ -324,6 +424,63 @@ class Migration(migrations.Migration): fields=["scheduled_start"], name="appointment_schedul_8a4e8e_idx" ), ), + migrations.AddIndex( + model_name="waitinglist", + index=models.Index( + fields=["tenant", "status"], name="appointment_tenant__a558da_idx" + ), + ), + migrations.AddIndex( + model_name="waitinglist", + index=models.Index( + fields=["patient", "status"], name="appointment_patient_73f03d_idx" + ), + ), + migrations.AddIndex( + model_name="waitinglist", + index=models.Index( + fields=["department", "specialty", "status"], + name="appointment_departm_78fd70_idx", + ), + ), + migrations.AddIndex( + model_name="waitinglist", + index=models.Index( + fields=["priority", "urgency_score"], + name="appointment_priorit_30fb90_idx", + ), + ), + migrations.AddIndex( + model_name="waitinglist", + index=models.Index( + fields=["status", "created_at"], name="appointment_status_cfe551_idx" + ), + ), + migrations.AddIndex( + model_name="waitinglist", + index=models.Index( + fields=["provider", "status"], name="appointment_provide_dd6c2b_idx" + ), + ), + migrations.AddIndex( + model_name="waitinglistcontactlog", + index=models.Index( + fields=["waiting_list_entry", "contact_date"], + name="appointment_waiting_50d8ac_idx", + ), + ), + migrations.AddIndex( + model_name="waitinglistcontactlog", + index=models.Index( + fields=["contact_outcome"], name="appointment_contact_ad9c45_idx" + ), + ), + migrations.AddIndex( + model_name="waitinglistcontactlog", + index=models.Index( + fields=["next_contact_date"], name="appointment_next_co_b29984_idx" + ), + ), migrations.AddIndex( model_name="waitingqueue", index=models.Index( diff --git a/appointments/migrations/0003_alter_waitinglist_acceptable_days.py b/appointments/migrations/0003_alter_waitinglist_acceptable_days.py new file mode 100644 index 00000000..15ce0c1a --- /dev/null +++ b/appointments/migrations/0003_alter_waitinglist_acceptable_days.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.6 on 2025-09-16 12:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("appointments", "0002_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="waitinglist", + name="acceptable_days", + field=models.JSONField( + blank=True, + default=list, + help_text="Acceptable days of week (0=Monday, 6=Sunday)", + null=True, + ), + ), + ] diff --git a/appointments/migrations/0003_waitinglist_waitinglistcontactlog_and_more.py b/appointments/migrations/0003_waitinglist_waitinglistcontactlog_and_more.py deleted file mode 100644 index 7b3fa2ed..00000000 --- a/appointments/migrations/0003_waitinglist_waitinglistcontactlog_and_more.py +++ /dev/null @@ -1,688 +0,0 @@ -# Generated by Django 5.2.6 on 2025-09-11 17:03 - -import django.core.validators -import django.db.models.deletion -import uuid -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("appointments", "0002_initial"), - ("core", "0001_initial"), - ("hr", "0001_initial"), - ("patients", "0003_remove_insuranceinfo_subscriber_ssn_and_more"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name="WaitingList", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "waiting_list_id", - models.UUIDField( - default=uuid.uuid4, - editable=False, - help_text="Unique waiting list entry identifier", - unique=True, - ), - ), - ( - "appointment_type", - models.CharField( - choices=[ - ("CONSULTATION", "Consultation"), - ("FOLLOW_UP", "Follow-up"), - ("PROCEDURE", "Procedure"), - ("SURGERY", "Surgery"), - ("DIAGNOSTIC", "Diagnostic"), - ("THERAPY", "Therapy"), - ("VACCINATION", "Vaccination"), - ("SCREENING", "Screening"), - ("EMERGENCY", "Emergency"), - ("TELEMEDICINE", "Telemedicine"), - ("OTHER", "Other"), - ], - help_text="Type of appointment requested", - max_length=50, - ), - ), - ( - "specialty", - models.CharField( - choices=[ - ("FAMILY_MEDICINE", "Family Medicine"), - ("INTERNAL_MEDICINE", "Internal Medicine"), - ("PEDIATRICS", "Pediatrics"), - ("CARDIOLOGY", "Cardiology"), - ("DERMATOLOGY", "Dermatology"), - ("ENDOCRINOLOGY", "Endocrinology"), - ("GASTROENTEROLOGY", "Gastroenterology"), - ("NEUROLOGY", "Neurology"), - ("ONCOLOGY", "Oncology"), - ("ORTHOPEDICS", "Orthopedics"), - ("PSYCHIATRY", "Psychiatry"), - ("RADIOLOGY", "Radiology"), - ("SURGERY", "Surgery"), - ("UROLOGY", "Urology"), - ("GYNECOLOGY", "Gynecology"), - ("OPHTHALMOLOGY", "Ophthalmology"), - ("ENT", "Ear, Nose & Throat"), - ("EMERGENCY", "Emergency Medicine"), - ("OTHER", "Other"), - ], - help_text="Medical specialty required", - max_length=100, - ), - ), - ( - "priority", - models.CharField( - choices=[ - ("ROUTINE", "Routine"), - ("URGENT", "Urgent"), - ("STAT", "STAT"), - ("EMERGENCY", "Emergency"), - ], - default="ROUTINE", - help_text="Clinical priority level", - max_length=20, - ), - ), - ( - "urgency_score", - models.PositiveIntegerField( - default=1, - help_text="Clinical urgency score (1-10, 10 being most urgent)", - validators=[ - django.core.validators.MinValueValidator(1), - django.core.validators.MaxValueValidator(10), - ], - ), - ), - ( - "clinical_indication", - models.TextField( - help_text="Clinical reason for appointment request" - ), - ), - ( - "diagnosis_codes", - models.JSONField( - blank=True, default=list, help_text="ICD-10 diagnosis codes" - ), - ), - ( - "preferred_date", - models.DateField( - blank=True, - help_text="Patient preferred appointment date", - null=True, - ), - ), - ( - "preferred_time", - models.TimeField( - blank=True, - help_text="Patient preferred appointment time", - null=True, - ), - ), - ( - "flexible_scheduling", - models.BooleanField( - default=True, - help_text="Patient accepts alternative dates/times", - ), - ), - ( - "earliest_acceptable_date", - models.DateField( - blank=True, - help_text="Earliest acceptable appointment date", - null=True, - ), - ), - ( - "latest_acceptable_date", - models.DateField( - blank=True, - help_text="Latest acceptable appointment date", - null=True, - ), - ), - ( - "acceptable_days", - models.JSONField( - blank=True, - default=list, - help_text="Acceptable days of week (0=Monday, 6=Sunday)", - ), - ), - ( - "acceptable_times", - models.JSONField( - blank=True, default=list, help_text="Acceptable time ranges" - ), - ), - ( - "contact_method", - models.CharField( - choices=[ - ("PHONE", "Phone"), - ("EMAIL", "Email"), - ("SMS", "SMS"), - ("PORTAL", "Patient Portal"), - ("MAIL", "Mail"), - ], - default="PHONE", - help_text="Preferred contact method", - max_length=20, - ), - ), - ( - "contact_phone", - models.CharField( - blank=True, - help_text="Contact phone number", - max_length=20, - null=True, - ), - ), - ( - "contact_email", - models.EmailField( - blank=True, - help_text="Contact email address", - max_length=254, - null=True, - ), - ), - ( - "status", - models.CharField( - choices=[ - ("ACTIVE", "Active"), - ("CONTACTED", "Contacted"), - ("OFFERED", "Appointment Offered"), - ("SCHEDULED", "Scheduled"), - ("CANCELLED", "Cancelled"), - ("EXPIRED", "Expired"), - ("TRANSFERRED", "Transferred"), - ], - default="ACTIVE", - help_text="Waiting list status", - max_length=20, - ), - ), - ( - "position", - models.PositiveIntegerField( - blank=True, - help_text="Position in waiting list queue", - null=True, - ), - ), - ( - "estimated_wait_time", - models.PositiveIntegerField( - blank=True, help_text="Estimated wait time in days", null=True - ), - ), - ( - "last_contacted", - models.DateTimeField( - blank=True, - help_text="Last contact attempt date/time", - null=True, - ), - ), - ( - "contact_attempts", - models.PositiveIntegerField( - default=0, help_text="Number of contact attempts made" - ), - ), - ( - "max_contact_attempts", - models.PositiveIntegerField( - default=3, help_text="Maximum contact attempts before expiring" - ), - ), - ( - "appointments_offered", - models.PositiveIntegerField( - default=0, help_text="Number of appointments offered" - ), - ), - ( - "appointments_declined", - models.PositiveIntegerField( - default=0, help_text="Number of appointments declined" - ), - ), - ( - "last_offer_date", - models.DateTimeField( - blank=True, - help_text="Date of last appointment offer", - null=True, - ), - ), - ( - "requires_interpreter", - models.BooleanField( - default=False, help_text="Patient requires interpreter services" - ), - ), - ( - "interpreter_language", - models.CharField( - blank=True, - help_text="Required interpreter language", - max_length=50, - null=True, - ), - ), - ( - "accessibility_requirements", - models.TextField( - blank=True, - help_text="Special accessibility requirements", - null=True, - ), - ), - ( - "transportation_needed", - models.BooleanField( - default=False, - help_text="Patient needs transportation assistance", - ), - ), - ( - "insurance_verified", - models.BooleanField( - default=False, help_text="Insurance coverage verified" - ), - ), - ( - "authorization_required", - models.BooleanField( - default=False, help_text="Prior authorization required" - ), - ), - ( - "authorization_status", - models.CharField( - choices=[ - ("NOT_REQUIRED", "Not Required"), - ("PENDING", "Pending"), - ("APPROVED", "Approved"), - ("DENIED", "Denied"), - ("EXPIRED", "Expired"), - ], - default="NOT_REQUIRED", - help_text="Authorization status", - max_length=20, - ), - ), - ( - "authorization_number", - models.CharField( - blank=True, - help_text="Authorization number", - max_length=100, - null=True, - ), - ), - ( - "referring_provider", - models.CharField( - blank=True, - help_text="Referring provider name", - max_length=200, - null=True, - ), - ), - ( - "referral_date", - models.DateField( - blank=True, help_text="Date of referral", null=True - ), - ), - ( - "referral_urgency", - models.CharField( - choices=[ - ("ROUTINE", "Routine"), - ("URGENT", "Urgent"), - ("STAT", "STAT"), - ], - default="ROUTINE", - help_text="Referral urgency level", - max_length=20, - ), - ), - ( - "removal_reason", - models.CharField( - blank=True, - choices=[ - ("SCHEDULED", "Appointment Scheduled"), - ("PATIENT_CANCELLED", "Patient Cancelled"), - ("PROVIDER_CANCELLED", "Provider Cancelled"), - ("NO_RESPONSE", "No Response to Contact"), - ("INSURANCE_ISSUE", "Insurance Issue"), - ("TRANSFERRED", "Transferred to Another Provider"), - ("EXPIRED", "Entry Expired"), - ("DUPLICATE", "Duplicate Entry"), - ("OTHER", "Other"), - ], - help_text="Reason for removal from waiting list", - max_length=50, - null=True, - ), - ), - ( - "removal_notes", - models.TextField( - blank=True, - help_text="Additional notes about removal", - null=True, - ), - ), - ( - "removed_at", - models.DateTimeField( - blank=True, - help_text="Date/time removed from waiting list", - null=True, - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "notes", - models.TextField( - blank=True, help_text="Additional notes and comments", null=True - ), - ), - ( - "created_by", - models.ForeignKey( - blank=True, - help_text="User who created the waiting list entry", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="created_waiting_list_entries", - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "department", - models.ForeignKey( - help_text="Department for appointment", - on_delete=django.db.models.deletion.CASCADE, - related_name="waiting_list_entries", - to="hr.department", - ), - ), - ( - "patient", - models.ForeignKey( - help_text="Patient on waiting list", - on_delete=django.db.models.deletion.CASCADE, - related_name="waiting_list_entries", - to="patients.patientprofile", - ), - ), - ( - "provider", - models.ForeignKey( - blank=True, - help_text="Preferred healthcare provider", - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="provider_waiting_list", - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "removed_by", - models.ForeignKey( - blank=True, - help_text="User who removed entry from waiting list", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="removed_waiting_list_entries", - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "scheduled_appointment", - models.ForeignKey( - blank=True, - help_text="Scheduled appointment from waiting list", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="waiting_list_entry", - to="appointments.appointmentrequest", - ), - ), - ( - "tenant", - models.ForeignKey( - help_text="Organization tenant", - on_delete=django.db.models.deletion.CASCADE, - related_name="waiting_list_entries", - to="core.tenant", - ), - ), - ], - options={ - "verbose_name": "Waiting List Entry", - "verbose_name_plural": "Waiting List Entries", - "db_table": "appointments_waiting_list", - "ordering": ["priority", "urgency_score", "created_at"], - }, - ), - migrations.CreateModel( - name="WaitingListContactLog", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "contact_date", - models.DateTimeField( - auto_now_add=True, help_text="Date and time of contact attempt" - ), - ), - ( - "contact_method", - models.CharField( - choices=[ - ("PHONE", "Phone Call"), - ("EMAIL", "Email"), - ("SMS", "SMS"), - ("PORTAL", "Patient Portal Message"), - ("MAIL", "Mail"), - ("IN_PERSON", "In Person"), - ], - help_text="Method of contact used", - max_length=20, - ), - ), - ( - "contact_outcome", - models.CharField( - choices=[ - ("SUCCESSFUL", "Successful Contact"), - ("NO_ANSWER", "No Answer"), - ("BUSY", "Line Busy"), - ("VOICEMAIL", "Left Voicemail"), - ("EMAIL_SENT", "Email Sent"), - ("EMAIL_BOUNCED", "Email Bounced"), - ("SMS_SENT", "SMS Sent"), - ("SMS_FAILED", "SMS Failed"), - ("WRONG_NUMBER", "Wrong Number"), - ("DECLINED", "Patient Declined"), - ], - help_text="Outcome of contact attempt", - max_length=20, - ), - ), - ( - "appointment_offered", - models.BooleanField( - default=False, - help_text="Appointment was offered during contact", - ), - ), - ( - "offered_date", - models.DateField( - blank=True, help_text="Date of offered appointment", null=True - ), - ), - ( - "offered_time", - models.TimeField( - blank=True, help_text="Time of offered appointment", null=True - ), - ), - ( - "patient_response", - models.CharField( - blank=True, - choices=[ - ("ACCEPTED", "Accepted Appointment"), - ("DECLINED", "Declined Appointment"), - ("REQUESTED_DIFFERENT", "Requested Different Time"), - ("WILL_CALL_BACK", "Will Call Back"), - ("NO_LONGER_NEEDED", "No Longer Needed"), - ("INSURANCE_ISSUE", "Insurance Issue"), - ("NO_RESPONSE", "No Response"), - ], - help_text="Patient response to contact", - max_length=20, - null=True, - ), - ), - ( - "notes", - models.TextField( - blank=True, help_text="Notes from contact attempt", null=True - ), - ), - ( - "next_contact_date", - models.DateField( - blank=True, - help_text="Scheduled date for next contact attempt", - null=True, - ), - ), - ( - "contacted_by", - models.ForeignKey( - blank=True, - help_text="Staff member who made contact", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "waiting_list_entry", - models.ForeignKey( - help_text="Associated waiting list entry", - on_delete=django.db.models.deletion.CASCADE, - related_name="contact_logs", - to="appointments.waitinglist", - ), - ), - ], - options={ - "verbose_name": "Waiting List Contact Log", - "verbose_name_plural": "Waiting List Contact Logs", - "db_table": "appointments_waiting_list_contact_log", - "ordering": ["-contact_date"], - }, - ), - migrations.AddIndex( - model_name="waitinglist", - index=models.Index( - fields=["tenant", "status"], name="appointment_tenant__a558da_idx" - ), - ), - migrations.AddIndex( - model_name="waitinglist", - index=models.Index( - fields=["patient", "status"], name="appointment_patient_73f03d_idx" - ), - ), - migrations.AddIndex( - model_name="waitinglist", - index=models.Index( - fields=["department", "specialty", "status"], - name="appointment_departm_78fd70_idx", - ), - ), - migrations.AddIndex( - model_name="waitinglist", - index=models.Index( - fields=["priority", "urgency_score"], - name="appointment_priorit_30fb90_idx", - ), - ), - migrations.AddIndex( - model_name="waitinglist", - index=models.Index( - fields=["status", "created_at"], name="appointment_status_cfe551_idx" - ), - ), - migrations.AddIndex( - model_name="waitinglist", - index=models.Index( - fields=["provider", "status"], name="appointment_provide_dd6c2b_idx" - ), - ), - migrations.AddIndex( - model_name="waitinglistcontactlog", - index=models.Index( - fields=["waiting_list_entry", "contact_date"], - name="appointment_waiting_50d8ac_idx", - ), - ), - migrations.AddIndex( - model_name="waitinglistcontactlog", - index=models.Index( - fields=["contact_outcome"], name="appointment_contact_ad9c45_idx" - ), - ), - migrations.AddIndex( - model_name="waitinglistcontactlog", - index=models.Index( - fields=["next_contact_date"], name="appointment_next_co_b29984_idx" - ), - ), - ] diff --git a/appointments/migrations/__pycache__/0001_initial.cpython-312.pyc b/appointments/migrations/__pycache__/0001_initial.cpython-312.pyc index 86ea2ae4..c029329c 100644 Binary files a/appointments/migrations/__pycache__/0001_initial.cpython-312.pyc and b/appointments/migrations/__pycache__/0001_initial.cpython-312.pyc differ diff --git a/appointments/migrations/__pycache__/0002_initial.cpython-312.pyc b/appointments/migrations/__pycache__/0002_initial.cpython-312.pyc index f9fe7034..e8ddf75a 100644 Binary files a/appointments/migrations/__pycache__/0002_initial.cpython-312.pyc and b/appointments/migrations/__pycache__/0002_initial.cpython-312.pyc differ diff --git a/appointments/migrations/__pycache__/0003_alter_waitinglist_acceptable_days.cpython-312.pyc b/appointments/migrations/__pycache__/0003_alter_waitinglist_acceptable_days.cpython-312.pyc new file mode 100644 index 00000000..6ae7458f Binary files /dev/null and b/appointments/migrations/__pycache__/0003_alter_waitinglist_acceptable_days.cpython-312.pyc differ diff --git a/appointments/migrations/__pycache__/0003_waitinglist_waitinglistcontactlog_and_more.cpython-312.pyc b/appointments/migrations/__pycache__/0003_waitinglist_waitinglistcontactlog_and_more.cpython-312.pyc deleted file mode 100644 index 12a8e50c..00000000 Binary files a/appointments/migrations/__pycache__/0003_waitinglist_waitinglistcontactlog_and_more.cpython-312.pyc and /dev/null differ diff --git a/appointments/models.py b/appointments/models.py index edb37303..06887680 100644 --- a/appointments/models.py +++ b/appointments/models.py @@ -1371,6 +1371,7 @@ class WaitingList(models.Model): acceptable_days = models.JSONField( default=list, + null=True, blank=True, help_text='Acceptable days of week (0=Monday, 6=Sunday)' ) diff --git a/appointments/templates/appointments/.DS_Store b/appointments/templates/appointments/.DS_Store index 4b8879d8..509758ba 100644 Binary files a/appointments/templates/appointments/.DS_Store and b/appointments/templates/appointments/.DS_Store differ diff --git a/appointments/templates/appointments/appointment_search.html b/appointments/templates/appointments/appointment_search.html index e593fd24..7f06a312 100644 --- a/appointments/templates/appointments/appointment_search.html +++ b/appointments/templates/appointments/appointment_search.html @@ -9,186 +9,186 @@ {% endblock %} {% block content %} -
-
-
-
- - -

Search Appointments

- -
-
-

Search Filters

-
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- -
-
+ +
+
+
+ + +

Search Appointments

+ +
+
+

Search Filters

- - {% if appointments %} -
-
-

Search Results ({{ appointments.count }} found)

-
-
-
- - - - - - - - - - - - - - {% for appointment in appointments %} - - - - - - - - - - {% endfor %} - -
Date/TimePatientProviderDepartmentTypeStatusActions
-
{{ appointment.appointment_date|date:"M d, Y" }}
- {{ appointment.appointment_time|time:"g:i A" }} -
-
{{ appointment.patient.first_name }} {{ appointment.patient.last_name }}
- ID: {{ appointment.patient.patient_id }} -
{{ appointment.provider.first_name }} {{ appointment.provider.last_name }}{{ appointment.department.name }}{{ appointment.appointment_type.name }} - {% if appointment.status == 'scheduled' %} - Scheduled - {% elif appointment.status == 'confirmed' %} - Confirmed - {% elif appointment.status == 'checked_in' %} - Checked In - {% elif appointment.status == 'in_progress' %} - In Progress - {% elif appointment.status == 'completed' %} - Completed - {% elif appointment.status == 'cancelled' %} - Cancelled - {% elif appointment.status == 'no_show' %} - No Show - {% endif %} - -
- - - - - - - {% if appointment.status == 'scheduled' or appointment.status == 'confirmed' %} - - - - {% endif %} -
-
+
+
+
+ +
-
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
- {% elif request.GET %} -
-
- -
No appointments found
-

Try adjusting your search criteria

-
-
- {% endif %}
+ + {% if appointments %} +
+
+

Search Results ({{ appointments.count }} found)

+
+
+
+ + + + + + + + + + + + + + {% for appointment in appointments %} + + + + + + + + + + {% endfor %} + +
Date/TimePatientProviderDepartmentTypeStatusActions
+
{{ appointment.appointment_date|date:"M d, Y" }}
+ {{ appointment.appointment_time|time:"g:i A" }} +
+
{{ appointment.patient.first_name }} {{ appointment.patient.last_name }}
+ ID: {{ appointment.patient.patient_id }} +
{{ appointment.provider.first_name }} {{ appointment.provider.last_name }}{{ appointment.department.name }}{{ appointment.appointment_type.name }} + {% if appointment.status == 'scheduled' %} + Scheduled + {% elif appointment.status == 'confirmed' %} + Confirmed + {% elif appointment.status == 'checked_in' %} + Checked In + {% elif appointment.status == 'in_progress' %} + In Progress + {% elif appointment.status == 'completed' %} + Completed + {% elif appointment.status == 'cancelled' %} + Cancelled + {% elif appointment.status == 'no_show' %} + No Show + {% endif %} + +
+ + + + + + + {% if appointment.status == 'scheduled' or appointment.status == 'confirmed' %} + + + + {% endif %} +
+
+
+
+
+ {% elif request.GET %} +
+
+ +
No appointments found
+

Try adjusting your search criteria

+
+
+ {% endif %}
+ {% endblock %} {% block js %} diff --git a/appointments/templates/appointments/appointment_stats.html b/appointments/templates/appointments/appointment_stats.html index 0367bb0f..0e1bc7df 100644 --- a/appointments/templates/appointments/appointment_stats.html +++ b/appointments/templates/appointments/appointment_stats.html @@ -8,232 +8,231 @@ {% endblock %} {% block content %} -
-
-
-
- - -

Appointment Statistics

- - -
-
-
-
-
-
-
{{ stats.total_appointments|default:0 }}
-
Total Appointments
-
-
- -
+ +
+
+
+ + +

Appointment Statistics

+ + +
+
+
+
+
+
+
{{ stats.total_appointments|default:0 }}
+
Total Appointments
-
-
-
-
-
-
-
-
-
{{ stats.completed_appointments|default:0 }}
-
Completed
-
-
- -
-
-
-
-
-
-
-
-
-
-
{{ stats.cancelled_appointments|default:0 }}
-
Cancelled
-
-
- -
-
-
-
-
-
-
-
-
-
-
{{ stats.no_show_appointments|default:0 }}
-
No Shows
-
-
- -
+
+
- -
-
- -
-
-

Appointments by Status

-
-
-
- - - - - - - - - - - {% for status in stats.by_status %} - - - - - - - {% endfor %} - -
StatusCountPercentageVisual
{{ status.status|title }}{{ status.count }}{{ status.percentage|floatformat:1 }}% -
-
-
-
+
+
+
+
+
+
{{ stats.completed_appointments|default:0 }}
+
Completed
-
-
- - -
-
-

Appointments by Department

-
-
-
- - - - - - - - - - - - {% for dept in stats.by_department %} - - - - - - - - {% endfor %} - -
DepartmentTotalCompletedCancelledNo Shows
{{ dept.department_name }}{{ dept.total }}{{ dept.completed }}{{ dept.cancelled }}{{ dept.no_shows }}
+
+
- -
- -
-
-

Key Metrics

-
-
-
-
Show Rate:
-
- {{ stats.show_rate|floatformat:1 }}% -
+
+
+
+
+
+
+
{{ stats.cancelled_appointments|default:0 }}
+
Cancelled
-
-
Completion Rate:
-
- {{ stats.completion_rate|floatformat:1 }}% -
-
-
-
Cancellation Rate:
-
- {{ stats.cancellation_rate|floatformat:1 }}% -
-
-
-
Average Duration:
-
{{ stats.avg_duration|default:"N/A" }} min
-
-
-
Peak Hour:
-
{{ stats.peak_hour|default:"N/A" }}
+
+
- - -
-
-

Top Providers

-
-
- {% for provider in stats.top_providers %} -
-
-
{{ provider.provider_name }}
- {{ provider.department }} -
-
-
{{ provider.appointment_count }}
- appointments -
+
+
+
+
+
+
+
+
{{ stats.no_show_appointments|default:0 }}
+
No Shows
+
+
+
- {% endfor %}
- - -
-
-

Recent Trends

+
+
+
+ +
+
+ +
+
+

Appointments by Status

+
+
+
+ + + + + + + + + + + {% for status in stats.by_status %} + + + + + + + {% endfor %} + +
StatusCountPercentageVisual
{{ status.status|title }}{{ status.count }}{{ status.percentage|floatformat:1 }}% +
+
+
+
-
-
-
This Week:
-
{{ stats.this_week|default:0 }}
+
+
+ + +
+
+

Appointments by Department

+
+
+
+ + + + + + + + + + + + {% for dept in stats.by_department %} + + + + + + + + {% endfor %} + +
DepartmentTotalCompletedCancelledNo Shows
{{ dept.department_name }}{{ dept.total }}{{ dept.completed }}{{ dept.cancelled }}{{ dept.no_shows }}
+
+
+
+
+ +
+ +
+
+

Key Metrics

+
+
+
+
Show Rate:
+
+ {{ stats.show_rate|floatformat:1 }}%
-
-
Last Week:
-
{{ stats.last_week|default:0 }}
+
+
+
Completion Rate:
+
+ {{ stats.completion_rate|floatformat:1 }}%
-
-
This Month:
-
{{ stats.this_month|default:0 }}
+
+
+
Cancellation Rate:
+
+ {{ stats.cancellation_rate|floatformat:1 }}%
-
-
Last Month:
-
{{ stats.last_month|default:0 }}
+
+
+
Average Duration:
+
{{ stats.avg_duration|default:"N/A" }} min
+
+
+
Peak Hour:
+
{{ stats.peak_hour|default:"N/A" }}
+
+
+
+ + +
+
+

Top Providers

+
+
+ {% for provider in stats.top_providers %} +
+
+
{{ provider.provider_name }}
+ {{ provider.department }}
+
+
{{ provider.appointment_count }}
+ appointments +
+
+ {% endfor %} +
+
+ + +
+
+

Recent Trends

+
+
+
+
This Week:
+
{{ stats.this_week|default:0 }}
+
+
+
Last Week:
+
{{ stats.last_week|default:0 }}
+
+
+
This Month:
+
{{ stats.this_month|default:0 }}
+
+
+
Last Month:
+
{{ stats.last_month|default:0 }}
@@ -242,6 +241,7 @@
+ {% endblock %} {% block js %} diff --git a/appointments/templates/appointments/calendar.html b/appointments/templates/appointments/calendar.html new file mode 100644 index 00000000..3ed08a5d --- /dev/null +++ b/appointments/templates/appointments/calendar.html @@ -0,0 +1,182 @@ +{# templates/appointments/calendar.html #} +{% extends "base.html" %} +{% load static %} + +{% block title %}Appointments Calendar{% endblock %} + +{% block css %} + +{% endblock %} + +{% block content %} +
+
+

+ SchedulingCalendar +

+

View your calendar and manage schedules.

+
+ +
+
+
+
+
+
+
+
+
+ +
+
+
Appointment Details
+
+
Click an event to see details.
+
+
+ +
+
Filters
+
+
+
+ + +
+
+ + +
+ +
+
+
+
+
+ +{# Optional: Bootstrap modal for full details #} + +{% endblock %} + +{% block js %} + + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/appointments/templates/appointments/calendar_appointments.html b/appointments/templates/appointments/calendar_appointments.html index a484a8ec..47ce17d8 100644 --- a/appointments/templates/appointments/calendar_appointments.html +++ b/appointments/templates/appointments/calendar_appointments.html @@ -7,152 +7,151 @@ {% endblock %} {% block content %} -
-
-
-
- - -
-

Appointment Calendar

-
- - New Appointment - + +
+
+
+ + +
+

Appointment Calendar

+ +
+ +
+
+
+
+
+
- -
-
-
-
-
+ +
+ +
+
+

Filters

+
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + +
+
+

Status Legend

+
+
+
+
+ Scheduled +
+
+
+ Confirmed +
+
+
+ Checked In +
+
+
+ In Progress +
+
+
+ Completed +
+
+
+ Cancelled +
+
+
+ No Show
- -
- -
-
-

Filters

-
-
-
-
- - -
-
- - -
-
- - -
- -
-
+ + +
+
+

Today's Appointments

- - -
-
-

Status Legend

-
-
-
-
- Scheduled +
+ {% for appointment in appointments %} +
+
+
{{ appointment.appointment_time|time:"g:i A" }}
+
{{ appointment.patient.first_name }} {{ appointment.patient.last_name }}
+
{{ appointment.provider.first_name }} {{ appointment.provider.last_name }}
-
-
- Confirmed -
-
-
- Checked In -
-
-
- In Progress -
-
-
- Completed -
-
-
- Cancelled -
-
-
- No Show +
+ {% if appointment.status == 'SCHEDULED' %} + Scheduled + {% elif appointment.status == 'CONFIRMED' %} + Confirmed + {% elif appointment.status == 'CHECKED_IN' %} + Checked In + {% elif appointment.status == 'IN_PROGRESS' %} + In Progress + {% elif appointment.status == 'COMPLETED' %} + Completed + {% elif appointment.status == 'CANCELLED' %} + Cancelled + {% elif appointment.status == 'NO_SHOW' %} + No Show + {% endif %}
-
- - -
-
-

Today's Appointments

-
-
- {% for appointment in appointments %} -
-
-
{{ appointment.appointment_time|time:"g:i A" }}
-
{{ appointment.patient.first_name }} {{ appointment.patient.last_name }}
-
{{ appointment.provider.first_name }} {{ appointment.provider.last_name }}
-
-
- {% if appointment.status == 'SCHEDULED' %} - Scheduled - {% elif appointment.status == 'CONFIRMED' %} - Confirmed - {% elif appointment.status == 'CHECKED_IN' %} - Checked In - {% elif appointment.status == 'IN_PROGRESS' %} - In Progress - {% elif appointment.status == 'COMPLETED' %} - Completed - {% elif appointment.status == 'CANCELLED' %} - Cancelled - {% elif appointment.status == 'NO_SHOW' %} - No Show - {% endif %} -
-
- {% empty %} -

No appointments today

- {% endfor %} -
+ {% empty %} +

No appointments today

+ {% endfor %}
@@ -161,6 +160,7 @@
+