diff --git a/.gitignore b/.gitignore index 6d8364a..4ad2ed8 100644 --- a/.gitignore +++ b/.gitignore @@ -73,9 +73,8 @@ Thumbs.db # Docker volumes postgres_data/ -# Django migrations (exclude __init__.py) -**/migrations/*.py -!**/migrations/__init__.py +# Django migrations +**/migrations/__pycache__/ # OpenCode skills .opencode/skills/ diff --git a/Caddyfile.test b/Caddyfile.test new file mode 100644 index 0000000..e4d181e --- /dev/null +++ b/Caddyfile.test @@ -0,0 +1,34 @@ +px360test2.tenhal.sa { + encode gzip + + handle_path /static/* { + root * /srv/static + file_server { + precompressed br gzip + } + } + + handle_path /media/* { + root * /srv/media + file_server + } + + handle { + reverse_proxy web:8000 + } + + log { + output file /var/log/caddy/access.log { + roll_size 10mb + roll_keep 5 + } + format json + } + + header { + X-Content-Type-Options nosniff + X-Frame-Options DENY + X-XSS-Protection "1; mode=block" + Referrer-Policy strict-origin-when-cross-origin + } +} diff --git a/apps/accounts/migrations/0001_initial.py b/apps/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ff23239 --- /dev/null +++ b/apps/accounts/migrations/0001_initial.py @@ -0,0 +1,294 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.utils.timezone +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('email', models.EmailField(max_length=254, unique=True)), + ('username', models.CharField(blank=True, default='', max_length=150)), + ('phone', models.CharField(blank=True, max_length=20)), + ('employee_id', models.CharField(blank=True, max_length=50)), + ('avatar', models.ImageField(blank=True, null=True, upload_to='avatars/')), + ('bio', models.TextField(blank=True)), + ('language', models.CharField(choices=[('en', 'English'), ('ar', 'Arabic')], default='en', max_length=5)), + ('notification_email_enabled', models.BooleanField(default=True, help_text='Enable email notifications')), + ('notification_sms_enabled', models.BooleanField(default=False, help_text='Enable SMS notifications')), + ('preferred_notification_channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('both', 'Both')], default='email', help_text='Preferred notification channel for general notifications', max_length=10)), + ('explanation_notification_channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('both', 'Both')], default='email', help_text='Preferred channel for explanation requests', max_length=10)), + ('is_active', models.BooleanField(default=True)), + ('is_provisional', models.BooleanField(default=False, help_text='User is in onboarding process')), + ('invitation_token', models.CharField(blank=True, help_text='Token for account activation', max_length=100, null=True, unique=True)), + ('invitation_expires_at', models.DateTimeField(blank=True, help_text='When the invitation token expires', null=True)), + ('acknowledgement_completed', models.BooleanField(default=False, help_text='User has completed acknowledgement wizard')), + ('acknowledgement_completed_at', models.DateTimeField(blank=True, help_text='When acknowledgement was completed', null=True)), + ('current_wizard_step', models.IntegerField(default=0, help_text='Current step in onboarding wizard')), + ('wizard_completed_steps', models.JSONField(blank=True, default=list, help_text='List of completed wizard step IDs')), + ], + options={ + 'ordering': ['-date_joined'], + }, + ), + migrations.CreateModel( + name='AcknowledgementCategory', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name_en', models.CharField(max_length=200)), + ('name_ar', models.CharField(blank=True, help_text='Arabic name', max_length=200)), + ('code', models.CharField(help_text='Unique code (e.g., CLINICS, ADMISSIONS)', max_length=50, unique=True)), + ('description', models.TextField(blank=True, help_text='Category description')), + ('icon', models.CharField(blank=True, help_text="Icon class (e.g., 'building', 'user')", max_length=50)), + ('color', models.CharField(default='#007bbd', help_text='Hex color code', max_length=7)), + ('order', models.IntegerField(default=0, help_text='Display order')), + ('is_active', models.BooleanField(db_index=True, default=True)), + ('is_default', models.BooleanField(default=False, help_text='One of the 14 default categories')), + ], + options={ + 'verbose_name_plural': 'Acknowledgement Categories', + 'ordering': ['order', 'name_en'], + }, + ), + migrations.CreateModel( + name='AcknowledgementChecklistItem', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('code', models.CharField(help_text='Unique code for this checklist item', max_length=100, unique=True)), + ('text_en', models.CharField(max_length=500)), + ('text_ar', models.CharField(blank=True, max_length=500)), + ('description_en', models.TextField(blank=True)), + ('description_ar', models.TextField(blank=True)), + ('is_required', models.BooleanField(default=True, help_text='Item must be acknowledged')), + ('order', models.IntegerField(default=0, help_text='Display order in checklist')), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'ordering': ['category', 'order', 'code'], + }, + ), + migrations.CreateModel( + name='AcknowledgementContent', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('code', models.CharField(help_text='Unique code for this content section', max_length=100, unique=True)), + ('title_en', models.CharField(max_length=200)), + ('title_ar', models.CharField(blank=True, max_length=200)), + ('description_en', models.TextField()), + ('description_ar', models.TextField(blank=True)), + ('content_en', models.TextField(blank=True)), + ('content_ar', models.TextField(blank=True)), + ('icon', models.CharField(blank=True, help_text="Icon class (e.g., 'fa-user', 'fa-shield')", max_length=50)), + ('color', models.CharField(blank=True, help_text="Hex color code (e.g., '#007bff')", max_length=7)), + ('order', models.IntegerField(default=0, help_text='Display order')), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'ordering': ['category', 'order', 'code'], + }, + ), + migrations.CreateModel( + name='ChecklistItemVersion', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('version_number', models.PositiveIntegerField(help_text='Version number (1, 2, 3, ...)')), + ('text_en', models.CharField(max_length=500)), + ('text_ar', models.CharField(blank=True, max_length=500)), + ('description_en', models.TextField(blank=True)), + ('description_ar', models.TextField(blank=True)), + ('is_required', models.BooleanField(default=True)), + ('is_active', models.BooleanField(default=True)), + ('order', models.IntegerField(default=0)), + ('role', models.CharField(blank=True, max_length=50, null=True)), + ('change_reason', models.TextField(blank=True, help_text='Reason for this change')), + ], + options={ + 'verbose_name': 'Checklist Item Version', + 'verbose_name_plural': 'Checklist Item Versions', + 'ordering': ['-version_number'], + }, + ), + migrations.CreateModel( + name='ContentChangeLog', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('action', models.CharField(choices=[('create', 'Created'), ('update', 'Updated'), ('delete', 'Deleted'), ('activate', 'Activated'), ('deactivate', 'Deactivated'), ('reorder', 'Reordered')], max_length=20)), + ('content_type', models.CharField(choices=[('content', 'Acknowledgement Content'), ('checklist_item', 'Checklist Item')], max_length=20)), + ('object_id', models.CharField(help_text='UUID of the content/checklist item', max_length=50)), + ('object_code', models.CharField(help_text='Human-readable code of the object', max_length=100)), + ('snapshot', models.JSONField(default=dict, help_text='JSON snapshot of the object after change')), + ('changes_summary', models.TextField(blank=True, help_text='Human-readable summary of changes')), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('user_agent', models.TextField(blank=True)), + ], + options={ + 'verbose_name': 'Content Change Log', + 'verbose_name_plural': 'Content Change Logs', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ContentVersion', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('version_number', models.PositiveIntegerField(help_text='Version number (1, 2, 3, ...)')), + ('title_en', models.CharField(max_length=200)), + ('title_ar', models.CharField(blank=True, max_length=200)), + ('description_en', models.TextField()), + ('description_ar', models.TextField(blank=True)), + ('content_en', models.TextField(blank=True)), + ('content_ar', models.TextField(blank=True)), + ('role', models.CharField(blank=True, max_length=50, null=True)), + ('order', models.IntegerField(default=0)), + ('is_active', models.BooleanField(default=True)), + ('change_reason', models.TextField(blank=True, help_text='Reason for this change')), + ], + options={ + 'verbose_name': 'Content Version', + 'verbose_name_plural': 'Content Versions', + 'ordering': ['-version_number'], + }, + ), + migrations.CreateModel( + name='EmployeeAcknowledgement', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('sent_at', models.DateTimeField(blank=True, help_text='When acknowledgement was sent to employee', null=True)), + ('is_signed', models.BooleanField(default=False)), + ('signed_at', models.DateTimeField(blank=True, null=True)), + ('signature_name', models.CharField(blank=True, help_text='Name used when signing', max_length=200)), + ('signature_employee_id', models.CharField(blank=True, help_text='Employee ID when signing', max_length=100)), + ('signed_pdf', models.FileField(blank=True, help_text='PDF with employee signature', null=True, upload_to='acknowledgements/signed/%Y/%m/')), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('user_agent', models.TextField(blank=True)), + ('notes', models.TextField(blank=True, help_text='Admin notes about this acknowledgement')), + ], + options={ + 'verbose_name': 'Employee Acknowledgement', + 'verbose_name_plural': 'Employee Acknowledgements', + 'ordering': ['-sent_at', '-signed_at'], + }, + ), + migrations.CreateModel( + name='Role', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(choices=[('px_admin', 'PX Admin'), ('hospital_admin', 'Hospital Admin'), ('department_manager', 'Department Manager'), ('director', 'Director'), ('champion', 'Champion'), ('px_management', 'PX Management'), ('px_employee', 'PX Employee'), ('staff', 'Staff'), ('viewer', 'Viewer'), ('executive', 'Executive')], max_length=50, unique=True)), + ('display_name', models.CharField(max_length=100)), + ('description', models.TextField(blank=True)), + ('level', models.IntegerField(default=0, help_text='Higher number = higher authority')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['-level', 'name'], + }, + ), + migrations.CreateModel( + name='SimpleAcknowledgement', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('title', models.CharField(help_text='Acknowledgement title', max_length=200)), + ('description', models.TextField(blank=True, help_text='Detailed description')), + ('pdf_document', models.FileField(blank=True, help_text='PDF document for employees to review', null=True, upload_to='acknowledgements/documents/')), + ('is_active', models.BooleanField(default=True, help_text='Show in employee checklist')), + ('is_required', models.BooleanField(default=True, help_text='Must be signed by all employees')), + ('order', models.IntegerField(default=0)), + ], + options={ + 'verbose_name': 'Acknowledgement', + 'verbose_name_plural': 'Acknowledgements', + 'ordering': ['order', 'title'], + }, + ), + migrations.CreateModel( + name='StaffActivityLog', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('activity_type', models.CharField(choices=[('login', 'Login'), ('logout', 'Logout'), ('create', 'Create'), ('update', 'Update'), ('delete', 'Delete'), ('view', 'View'), ('assign', 'Assign'), ('transfer', 'Transfer'), ('send', 'Send'), ('approve', 'Approve'), ('reject', 'Reject'), ('resolve', 'Resolve'), ('reopen', 'Reopen'), ('export', 'Export'), ('analyze', 'Analyze')], db_index=True, max_length=20)), + ('description', models.TextField(blank=True)), + ('object_id', models.UUIDField(blank=True, null=True)), + ('module', models.CharField(blank=True, help_text='App/module name (e.g., complaints, surveys)', max_length=50)), + ('action', models.CharField(blank=True, help_text='Specific action identifier', max_length=100)), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('user_agent', models.TextField(blank=True)), + ('metadata', models.JSONField(blank=True, default=dict)), + ], + options={ + 'verbose_name': 'Staff Activity Log', + 'verbose_name_plural': 'Staff Activity Logs', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='UserAcknowledgement', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('is_acknowledged', models.BooleanField(default=True)), + ('acknowledged_at', models.DateTimeField(auto_now_add=True)), + ('signature', models.TextField(blank=True, help_text='Digital signature data (base64 encoded)')), + ('signature_ip', models.GenericIPAddressField(blank=True, help_text='IP address when signed', null=True)), + ('signature_user_agent', models.TextField(blank=True, help_text='User agent when signed')), + ('pdf_file', models.FileField(blank=True, help_text='PDF document of signed acknowledgement', null=True, upload_to='acknowledgements/pdfs/')), + ('metadata', models.JSONField(blank=True, default=dict, help_text='Additional metadata')), + ], + options={ + 'ordering': ['-acknowledged_at'], + }, + ), + migrations.CreateModel( + name='UserProvisionalLog', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('event_type', models.CharField(choices=[('created', 'User Created'), ('invitation_sent', 'Invitation Sent'), ('invitation_resent', 'Invitation Resent'), ('wizard_started', 'Wizard Started'), ('step_completed', 'Wizard Step Completed'), ('wizard_completed', 'Wizard Completed'), ('user_activated', 'User Activated'), ('invitation_expired', 'Invitation Expired')], db_index=True, max_length=50)), + ('description', models.TextField()), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('user_agent', models.TextField(blank=True)), + ('metadata', models.JSONField(blank=True, default=dict, help_text='Additional event data')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/apps/accounts/migrations/0002_initial.py b/apps/accounts/migrations/0002_initial.py new file mode 100644 index 0000000..da29776 --- /dev/null +++ b/apps/accounts/migrations/0002_initial.py @@ -0,0 +1,203 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('accounts', '0001_initial'), + ('auth', '0012_alter_user_first_name_max_length'), + ('contenttypes', '0002_remove_content_type_name'), + ('organizations', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='department', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='users', to='organizations.department'), + ), + migrations.AddField( + model_name='user', + name='groups', + field=models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups'), + ), + migrations.AddField( + model_name='user', + name='hospital', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='users', to='organizations.hospital'), + ), + migrations.AddField( + model_name='user', + name='user_permissions', + field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions'), + ), + migrations.AddIndex( + model_name='acknowledgementcategory', + index=models.Index(fields=['is_active', 'order'], name='accounts_ac_is_acti_333162_idx'), + ), + migrations.AddIndex( + model_name='acknowledgementcategory', + index=models.Index(fields=['is_default'], name='accounts_ac_is_defa_26f15a_idx'), + ), + migrations.AddField( + model_name='acknowledgementchecklistitem', + name='category', + field=models.ForeignKey(help_text='Category/Department this acknowledgement belongs to', on_delete=django.db.models.deletion.PROTECT, related_name='checklist_items', to='accounts.acknowledgementcategory'), + ), + migrations.AddField( + model_name='acknowledgementcontent', + name='category', + field=models.ForeignKey(help_text='Category this content belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='content_sections', to='accounts.acknowledgementcategory'), + ), + migrations.AddField( + model_name='acknowledgementchecklistitem', + name='content', + field=models.ForeignKey(blank=True, help_text='Related content section', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='checklist_items', to='accounts.acknowledgementcontent'), + ), + migrations.AddField( + model_name='checklistitemversion', + name='changed_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='checklist_versions_created', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='checklistitemversion', + name='checklist_item', + field=models.ForeignKey(help_text='The checklist item this version belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='accounts.acknowledgementchecklistitem'), + ), + migrations.AddField( + model_name='checklistitemversion', + name='content', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='checklist_versions', to='accounts.acknowledgementcontent'), + ), + migrations.AddField( + model_name='contentchangelog', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='content_change_logs', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='contentversion', + name='changed_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='content_versions_created', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='contentversion', + name='content', + field=models.ForeignKey(help_text='The content this version belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='accounts.acknowledgementcontent'), + ), + migrations.AddField( + model_name='employeeacknowledgement', + name='employee', + field=models.ForeignKey(help_text='Employee who signed', on_delete=django.db.models.deletion.CASCADE, related_name='employee_acknowledgements', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='employeeacknowledgement', + name='sent_by', + field=models.ForeignKey(blank=True, help_text='Admin who sent this acknowledgement', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_acknowledgements', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='role', + name='group', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='role_config', to='auth.group'), + ), + migrations.AddField( + model_name='role', + name='permissions', + field=models.ManyToManyField(blank=True, to='auth.permission'), + ), + migrations.AddField( + model_name='employeeacknowledgement', + name='acknowledgement', + field=models.ForeignKey(help_text='Acknowledgement that was signed', on_delete=django.db.models.deletion.CASCADE, related_name='employee_signatures', to='accounts.simpleacknowledgement'), + ), + migrations.AddField( + model_name='staffactivitylog', + name='content_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='contenttypes.contenttype'), + ), + migrations.AddField( + model_name='staffactivitylog', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='activity_logs', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='useracknowledgement', + name='checklist_item', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_acknowledgements', to='accounts.acknowledgementchecklistitem'), + ), + migrations.AddField( + model_name='useracknowledgement', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='acknowledgements', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='userprovisionallog', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='provisional_logs', to=settings.AUTH_USER_MODEL), + ), + migrations.AddIndex( + model_name='user', + index=models.Index(fields=['is_active', '-date_joined'], name='accounts_us_is_acti_a32178_idx'), + ), + migrations.AddIndex( + model_name='acknowledgementcontent', + index=models.Index(fields=['category', 'is_active', 'order'], name='accounts_ac_categor_6ccab8_idx'), + ), + migrations.AddIndex( + model_name='acknowledgementchecklistitem', + index=models.Index(fields=['category', 'is_active', 'order'], name='accounts_ac_categor_d5334e_idx'), + ), + migrations.AlterUniqueTogether( + name='checklistitemversion', + unique_together={('checklist_item', 'version_number')}, + ), + migrations.AlterUniqueTogether( + name='contentversion', + unique_together={('content', 'version_number')}, + ), + migrations.AlterUniqueTogether( + name='employeeacknowledgement', + unique_together={('employee', 'acknowledgement')}, + ), + migrations.AddIndex( + model_name='staffactivitylog', + index=models.Index(fields=['user', '-created_at'], name='accounts_st_user_id_430335_idx'), + ), + migrations.AddIndex( + model_name='staffactivitylog', + index=models.Index(fields=['activity_type', '-created_at'], name='accounts_st_activit_8a40be_idx'), + ), + migrations.AddIndex( + model_name='staffactivitylog', + index=models.Index(fields=['module', '-created_at'], name='accounts_st_module_298d52_idx'), + ), + migrations.AddIndex( + model_name='staffactivitylog', + index=models.Index(fields=['content_type', 'object_id'], name='accounts_st_content_9ead09_idx'), + ), + migrations.AddIndex( + model_name='useracknowledgement', + index=models.Index(fields=['user', '-acknowledged_at'], name='accounts_us_user_id_7ba948_idx'), + ), + migrations.AddIndex( + model_name='useracknowledgement', + index=models.Index(fields=['checklist_item', '-acknowledged_at'], name='accounts_us_checkli_870e26_idx'), + ), + migrations.AlterUniqueTogether( + name='useracknowledgement', + unique_together={('user', 'checklist_item')}, + ), + migrations.AddIndex( + model_name='userprovisionallog', + index=models.Index(fields=['user', '-created_at'], name='accounts_us_user_id_c488d5_idx'), + ), + migrations.AddIndex( + model_name='userprovisionallog', + index=models.Index(fields=['event_type', '-created_at'], name='accounts_us_event_t_b7f691_idx'), + ), + ] diff --git a/apps/ai_engine/migrations/0001_initial.py b/apps/ai_engine/migrations/0001_initial.py new file mode 100644 index 0000000..aca6a22 --- /dev/null +++ b/apps/ai_engine/migrations/0001_initial.py @@ -0,0 +1,43 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ] + + operations = [ + migrations.CreateModel( + name='SentimentResult', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('object_id', models.UUIDField()), + ('text', models.TextField(help_text='Text that was analyzed')), + ('language', models.CharField(choices=[('en', 'English'), ('ar', 'Arabic')], default='en', max_length=5)), + ('sentiment', models.CharField(choices=[('positive', 'Positive'), ('neutral', 'Neutral'), ('negative', 'Negative')], db_index=True, max_length=20)), + ('sentiment_score', models.DecimalField(decimal_places=4, help_text='Sentiment score from -1 (negative) to 1 (positive)', max_digits=5)), + ('confidence', models.DecimalField(decimal_places=4, help_text='Confidence level of the sentiment analysis', max_digits=5)), + ('ai_service', models.CharField(default='stub', help_text="AI service used (e.g., 'openai', 'azure', 'aws', 'stub')", max_length=100)), + ('ai_model', models.CharField(blank=True, help_text='Specific AI model used', max_length=100)), + ('processing_time_ms', models.IntegerField(blank=True, help_text='Time taken to analyze (milliseconds)', null=True)), + ('keywords', models.JSONField(blank=True, default=list, help_text='Extracted keywords')), + ('entities', models.JSONField(blank=True, default=list, help_text='Extracted entities (people, places, etc.)')), + ('emotions', models.JSONField(blank=True, default=dict, help_text='Emotion scores (joy, anger, sadness, etc.)')), + ('metadata', models.JSONField(blank=True, default=dict)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ], + options={ + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['sentiment', '-created_at'], name='ai_engine_s_sentime_e4f801_idx'), models.Index(fields=['content_type', 'object_id'], name='ai_engine_s_content_eb5a8a_idx')], + }, + ), + ] diff --git a/apps/analytics/migrations/0001_initial.py b/apps/analytics/migrations/0001_initial.py new file mode 100644 index 0000000..69ba802 --- /dev/null +++ b/apps/analytics/migrations/0001_initial.py @@ -0,0 +1,218 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('organizations', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='KPI', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200, unique=True)), + ('name_ar', models.CharField(blank=True, max_length=200)), + ('description', models.TextField(blank=True)), + ('category', models.CharField(choices=[('patient_satisfaction', 'Patient Satisfaction'), ('complaint_management', 'Complaint Management'), ('action_management', 'Action Management'), ('sla_compliance', 'SLA Compliance'), ('survey_response', 'Survey Response'), ('operational', 'Operational')], db_index=True, max_length=100)), + ('unit', models.CharField(help_text='Unit of measurement (%, count, hours, etc.)', max_length=50)), + ('calculation_method', models.TextField(help_text='Description of how this KPI is calculated')), + ('target_value', models.DecimalField(blank=True, decimal_places=2, help_text='Target value for this KPI', max_digits=10, null=True)), + ('warning_threshold', models.DecimalField(blank=True, decimal_places=2, help_text='Warning threshold', max_digits=10, null=True)), + ('critical_threshold', models.DecimalField(blank=True, decimal_places=2, help_text='Critical threshold', max_digits=10, null=True)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'verbose_name': 'KPI', + 'verbose_name_plural': 'KPIs', + 'ordering': ['category', 'name'], + }, + ), + migrations.CreateModel( + name='KPIReport', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('report_type', models.CharField(choices=[('resolution_72h', 'MOH-2: 72-Hour Resolution Rate'), ('patient_experience', 'MOH-1: Patient Experience Score'), ('satisfaction_resolution', 'MOH-3: Overall Satisfaction with Resolution'), ('n_pad_001', 'N-PAD-001: Resolution to Patient Complaints'), ('response_rate', 'Dep-KPI-4: Department Response Rate'), ('activation_2h', 'KPI-6: Complaint Activation Within 2 Hours'), ('unactivated', 'KPI-7: Unactivated Filled Complaints Rate'), ('moh_24h', 'MOH-24h: 24-Hour MOH Complaint Resolution Rate'), ('chi_48h', 'CHI-48h: 48-Hour CHI Complaint Resolution Rate')], db_index=True, help_text='Type of KPI report', max_length=50)), + ('year', models.IntegerField(db_index=True)), + ('month', models.IntegerField(db_index=True)), + ('report_date', models.DateField(help_text='Date the report was generated')), + ('status', models.CharField(choices=[('pending', 'Pending'), ('generating', 'Generating'), ('completed', 'Completed'), ('failed', 'Failed')], db_index=True, default='pending', max_length=20)), + ('generated_at', models.DateTimeField(blank=True, null=True)), + ('target_percentage', models.DecimalField(decimal_places=2, default=95.0, help_text='Target percentage for this KPI', max_digits=5)), + ('threshold_percentage', models.DecimalField(decimal_places=2, default=90.0, help_text='Threshold (minimum acceptable) percentage for this KPI', max_digits=5)), + ('category', models.CharField(default='Organizational', help_text='Report category (e.g., Organizational, Clinical)', max_length=50)), + ('kpi_type', models.CharField(default='Outcome', help_text='KPI type (e.g., Outcome, Process, Structure)', max_length=50)), + ('risk_level', models.CharField(choices=[('High', 'High'), ('Medium', 'Medium'), ('Low', 'Low')], default='High', help_text='Risk level for this KPI', max_length=20)), + ('data_collection_method', models.CharField(default='Retrospective', help_text='Data collection method', max_length=50)), + ('data_collection_frequency', models.CharField(default='Monthly', help_text='How often data is collected', max_length=50)), + ('reporting_frequency', models.CharField(default='Monthly', help_text='How often report is generated', max_length=50)), + ('dimension', models.CharField(default='Efficiency', help_text='KPI dimension (e.g., Efficiency, Quality, Safety)', max_length=50)), + ('collector_name', models.CharField(blank=True, help_text='Name of data collector', max_length=200)), + ('analyzer_name', models.CharField(blank=True, help_text='Name of data analyzer', max_length=200)), + ('total_numerator', models.IntegerField(default=0, help_text='Total count of successful outcomes')), + ('total_denominator', models.IntegerField(default=0, help_text='Total count of all cases')), + ('overall_result', models.DecimalField(decimal_places=2, default=0.0, help_text='Overall percentage result', max_digits=6)), + ('error_message', models.TextField(blank=True)), + ('ai_analysis', models.JSONField(blank=True, help_text='AI-generated analysis and recommendations for this report', null=True)), + ('ai_analysis_generated_at', models.DateTimeField(blank=True, help_text='When the AI analysis was generated', null=True)), + ('generated_by', models.ForeignKey(blank=True, help_text='User who generated the report (null for automated)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='generated_kpi_reports', to=settings.AUTH_USER_MODEL)), + ('hospital', models.ForeignKey(help_text='Hospital this report belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='kpi_reports', to='organizations.hospital')), + ], + options={ + 'verbose_name': 'KPI Report', + 'verbose_name_plural': 'KPI Reports', + 'ordering': ['-year', '-month', 'report_type'], + }, + ), + migrations.CreateModel( + name='KPIReportDepartmentBreakdown', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('department_category', models.CharField(choices=[('medical', 'Medical Department'), ('nursing', 'Nursing Department'), ('non_medical', 'Non-Medical / Admin'), ('support_services', 'Support Services')], help_text='Category of department', max_length=50)), + ('complaint_count', models.IntegerField(default=0)), + ('resolved_count', models.IntegerField(default=0)), + ('avg_resolution_days', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True)), + ('top_areas', models.TextField(blank=True, help_text='Top complaint areas or notes (newline-separated)')), + ('details', models.JSONField(blank=True, default=dict)), + ('kpi_report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='department_breakdowns', to='analytics.kpireport')), + ], + options={ + 'verbose_name': 'KPI Department Breakdown', + 'verbose_name_plural': 'KPI Department Breakdowns', + 'ordering': ['department_category'], + }, + ), + migrations.CreateModel( + name='KPIReportLocationBreakdown', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('location_type', models.CharField(choices=[('Inpatient', 'Inpatient'), ('Outpatient', 'Outpatient'), ('Emergency', 'Emergency')], help_text='Location type category', max_length=50)), + ('complaint_count', models.IntegerField(default=0)), + ('percentage', models.DecimalField(decimal_places=2, default=0.0, max_digits=5)), + ('kpi_report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='location_breakdowns', to='analytics.kpireport')), + ], + options={ + 'verbose_name': 'KPI Location Breakdown', + 'verbose_name_plural': 'KPI Location Breakdowns', + 'ordering': ['location_type'], + }, + ), + migrations.CreateModel( + name='KPIReportMonthlyData', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('month', models.IntegerField(db_index=True, help_text='Month number (1-12), 0 for TOTAL')), + ('numerator', models.IntegerField(default=0, help_text='Count of successful outcomes')), + ('denominator', models.IntegerField(default=0, help_text='Count of all cases')), + ('percentage', models.DecimalField(decimal_places=2, default=0.0, help_text='Calculated percentage', max_digits=6)), + ('is_below_target', models.BooleanField(default=False, help_text='Whether this month is below target')), + ('details', models.JSONField(blank=True, default=dict, help_text='Additional breakdown data (e.g., by source, department)')), + ('kpi_report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='monthly_data', to='analytics.kpireport')), + ], + options={ + 'verbose_name': 'KPI Monthly Data', + 'verbose_name_plural': 'KPI Monthly Data', + 'ordering': ['month'], + }, + ), + migrations.CreateModel( + name='KPIReportSourceBreakdown', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('source_name', models.CharField(max_length=100)), + ('complaint_count', models.IntegerField(default=0)), + ('percentage', models.DecimalField(decimal_places=2, default=0.0, max_digits=5)), + ('kpi_report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='source_breakdowns', to='analytics.kpireport')), + ], + options={ + 'verbose_name': 'KPI Source Breakdown', + 'verbose_name_plural': 'KPI Source Breakdowns', + 'ordering': ['-complaint_count'], + }, + ), + migrations.CreateModel( + name='KPIValue', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('value', models.DecimalField(decimal_places=2, max_digits=10)), + ('period_start', models.DateTimeField(db_index=True)), + ('period_end', models.DateTimeField(db_index=True)), + ('period_type', models.CharField(choices=[('hourly', 'Hourly'), ('daily', 'Daily'), ('weekly', 'Weekly'), ('monthly', 'Monthly'), ('quarterly', 'Quarterly'), ('yearly', 'Yearly')], default='daily', max_length=20)), + ('status', models.CharField(choices=[('on_target', 'On Target'), ('warning', 'Warning'), ('critical', 'Critical')], db_index=True, max_length=20)), + ('metadata', models.JSONField(blank=True, default=dict, help_text='Additional calculation details')), + ('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='kpi_values', to='organizations.department')), + ('hospital', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='kpi_values', to='organizations.hospital')), + ('kpi', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='values', to='analytics.kpi')), + ], + options={ + 'ordering': ['-period_end'], + }, + ), + migrations.AddIndex( + model_name='kpireport', + index=models.Index(fields=['report_type', '-year', '-month'], name='analytics_k_report__c0826c_idx'), + ), + migrations.AddIndex( + model_name='kpireport', + index=models.Index(fields=['hospital', '-year', '-month'], name='analytics_k_hospita_ba868a_idx'), + ), + migrations.AddIndex( + model_name='kpireport', + index=models.Index(fields=['status', '-created_at'], name='analytics_k_status_8c6a24_idx'), + ), + migrations.AlterUniqueTogether( + name='kpireport', + unique_together={('report_type', 'hospital', 'year', 'month')}, + ), + migrations.AlterUniqueTogether( + name='kpireportdepartmentbreakdown', + unique_together={('kpi_report', 'department_category')}, + ), + migrations.AlterUniqueTogether( + name='kpireportlocationbreakdown', + unique_together={('kpi_report', 'location_type')}, + ), + migrations.AddIndex( + model_name='kpireportmonthlydata', + index=models.Index(fields=['kpi_report', 'month'], name='analytics_k_kpi_rep_0c4281_idx'), + ), + migrations.AlterUniqueTogether( + name='kpireportmonthlydata', + unique_together={('kpi_report', 'month')}, + ), + migrations.AddIndex( + model_name='kpivalue', + index=models.Index(fields=['kpi', '-period_end'], name='analytics_k_kpi_id_f9c38d_idx'), + ), + migrations.AddIndex( + model_name='kpivalue', + index=models.Index(fields=['hospital', 'kpi', '-period_end'], name='analytics_k_hospita_356dca_idx'), + ), + migrations.AddIndex( + model_name='kpivalue', + index=models.Index(fields=['hospital', 'department', '-period_end'], name='analytics_k_hospita_ba95bd_idx'), + ), + ] diff --git a/apps/appreciation/migrations/0001_initial.py b/apps/appreciation/migrations/0001_initial.py new file mode 100644 index 0000000..b4211ea --- /dev/null +++ b/apps/appreciation/migrations/0001_initial.py @@ -0,0 +1,203 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('organizations', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AppreciationBadge', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('code', models.CharField(help_text='Unique badge code', max_length=50, unique=True)), + ('name_en', models.CharField(max_length=200)), + ('name_ar', models.CharField(blank=True, max_length=200)), + ('description_en', models.TextField(blank=True)), + ('description_ar', models.TextField(blank=True)), + ('icon', models.CharField(blank=True, help_text='Icon class', max_length=50)), + ('color', models.CharField(blank=True, help_text='Hex color code', max_length=7)), + ('criteria_type', models.CharField(choices=[('received_count', 'Total Appreciations Received'), ('received_month', 'Appreciations Received in a Month'), ('streak_weeks', 'Consecutive Weeks with Appreciation'), ('diverse_senders', 'Appreciations from Different Senders')], db_index=True, max_length=50)), + ('criteria_value', models.IntegerField(help_text='Value to achieve (e.g., 10 for 10 appreciations)')), + ('order', models.IntegerField(default=0)), + ('is_active', models.BooleanField(default=True)), + ('hospital', models.ForeignKey(blank=True, help_text='Leave blank for system-wide badges', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='appreciation_badges', to='organizations.hospital')), + ], + options={ + 'ordering': ['hospital', 'order', 'name_en'], + }, + ), + migrations.CreateModel( + name='AppreciationCategory', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('code', models.CharField(help_text='Unique code for this category', max_length=50)), + ('name_en', models.CharField(max_length=200)), + ('name_ar', models.CharField(blank=True, max_length=200)), + ('description_en', models.TextField(blank=True)), + ('description_ar', models.TextField(blank=True)), + ('icon', models.CharField(blank=True, help_text="Icon class (e.g., 'fa-heart', 'fa-star')", max_length=50)), + ('color', models.CharField(blank=True, help_text="Hex color code (e.g., '#FF5733')", max_length=7)), + ('order', models.IntegerField(default=0, help_text='Display order')), + ('is_active', models.BooleanField(default=True)), + ('hospital', models.ForeignKey(blank=True, help_text='Leave blank for system-wide categories', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='appreciation_categories', to='organizations.hospital')), + ], + options={ + 'verbose_name_plural': 'Appreciation Categories', + 'ordering': ['hospital', 'order', 'name_en'], + }, + ), + migrations.CreateModel( + name='Appreciation', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('is_deleted', models.BooleanField(db_index=True, default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('recipient_object_id', models.UUIDField(blank=True, null=True)), + ('message_en', models.TextField()), + ('message_ar', models.TextField(blank=True)), + ('visibility', models.CharField(choices=[('private', 'Private'), ('department', 'Department'), ('hospital', 'Hospital'), ('public', 'Public')], db_index=True, default='private', max_length=20)), + ('status', models.CharField(choices=[('draft', 'Draft'), ('activated', 'Activated'), ('ai_analyzed', 'AI Analyzed'), ('sent', 'Sent'), ('acknowledged', 'Acknowledged')], db_index=True, default='draft', max_length=20)), + ('is_anonymous', models.BooleanField(default=False, help_text='Hide sender identity from recipient')), + ('activated_at', models.DateTimeField(blank=True, null=True)), + ('ai_analyzed_at', models.DateTimeField(blank=True, null=True)), + ('sent_at', models.DateTimeField(blank=True, null=True)), + ('acknowledged_at', models.DateTimeField(blank=True, null=True)), + ('send_to_manager', models.BooleanField(default=False, help_text='Send notification to department manager')), + ('send_to_department', models.BooleanField(default=True, help_text='Send notification to department')), + ('cc_list', models.JSONField(blank=True, default=list, help_text='List of user IDs to CC')), + ('custom_message', models.TextField(blank=True, help_text='Custom message to include when sending')), + ('ai_analysis', models.JSONField(blank=True, default=dict, help_text='AI analysis results')), + ('notification_sent', models.BooleanField(default=False)), + ('notification_sent_at', models.DateTimeField(blank=True, null=True)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('activated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='activated_appreciations', to=settings.AUTH_USER_MODEL)), + ('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_%(class)s_set', to=settings.AUTH_USER_MODEL)), + ('department', models.ForeignKey(blank=True, help_text='Department context (if applicable)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciations', to='organizations.department')), + ('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='appreciations', to='organizations.hospital')), + ('location', models.ForeignKey(blank=True, help_text='Location where the appreciation event occurred', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciations', to='organizations.location')), + ('main_section', models.ForeignKey(blank=True, help_text='Main section within the location', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciations', to='organizations.mainsection')), + ('recipient_content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciation_recipients', to='contenttypes.contenttype')), + ('sender', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_appreciations', to=settings.AUTH_USER_MODEL)), + ('subsection', models.ForeignKey(blank=True, help_text='Specific subsection', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciations', to='organizations.subsection')), + ('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciations', to='appreciation.appreciationcategory')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='AppreciationStats', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('recipient_object_id', models.UUIDField(blank=True, null=True)), + ('year', models.IntegerField(db_index=True)), + ('month', models.IntegerField(db_index=True, help_text='1-12')), + ('received_count', models.IntegerField(default=0)), + ('sent_count', models.IntegerField(default=0)), + ('acknowledged_count', models.IntegerField(default=0)), + ('hospital_rank', models.IntegerField(blank=True, help_text='Rank within hospital', null=True)), + ('department_rank', models.IntegerField(blank=True, help_text='Rank within department', null=True)), + ('category_breakdown', models.JSONField(blank=True, default=dict, help_text='Breakdown by category ID and count')), + ('metadata', models.JSONField(blank=True, default=dict)), + ('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciation_stats', to='organizations.department')), + ('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='appreciation_stats', to='organizations.hospital')), + ('recipient_content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciation_stats_recipients', to='contenttypes.contenttype')), + ], + options={ + 'ordering': ['-year', '-month', '-received_count'], + }, + ), + migrations.CreateModel( + name='UserBadge', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('recipient_object_id', models.UUIDField(blank=True, null=True)), + ('earned_at', models.DateTimeField(auto_now_add=True)), + ('appreciation_count', models.IntegerField(help_text='Count when badge was earned')), + ('metadata', models.JSONField(blank=True, default=dict)), + ('badge', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='earned_by', to='appreciation.appreciationbadge')), + ('recipient_content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='earned_badges_recipients', to='contenttypes.contenttype')), + ], + options={ + 'ordering': ['-earned_at'], + }, + ), + migrations.AddIndex( + model_name='appreciationbadge', + index=models.Index(fields=['hospital', 'is_active'], name='appreciatio_hospita_3847f7_idx'), + ), + migrations.AddIndex( + model_name='appreciationcategory', + index=models.Index(fields=['hospital', 'is_active'], name='appreciatio_hospita_b8e413_idx'), + ), + migrations.AddIndex( + model_name='appreciationcategory', + index=models.Index(fields=['code'], name='appreciatio_code_50215a_idx'), + ), + migrations.AlterUniqueTogether( + name='appreciationcategory', + unique_together={('hospital', 'code')}, + ), + migrations.AddIndex( + model_name='appreciation', + index=models.Index(fields=['status', '-created_at'], name='appreciatio_status_24158d_idx'), + ), + migrations.AddIndex( + model_name='appreciation', + index=models.Index(fields=['hospital', 'status'], name='appreciatio_hospita_db3f34_idx'), + ), + migrations.AddIndex( + model_name='appreciation', + index=models.Index(fields=['department', '-created_at'], name='appreciatio_departm_c2ddad_idx'), + ), + migrations.AddIndex( + model_name='appreciation', + index=models.Index(fields=['sender', '-created_at'], name='appreciatio_sender__934838_idx'), + ), + migrations.AddIndex( + model_name='appreciation', + index=models.Index(fields=['recipient_content_type', 'recipient_object_id', '-created_at'], name='appreciatio_recipie_71ef0e_idx'), + ), + migrations.AddIndex( + model_name='appreciation', + index=models.Index(fields=['visibility', '-created_at'], name='appreciatio_visibil_ed96d9_idx'), + ), + migrations.AddIndex( + model_name='appreciationstats', + index=models.Index(fields=['hospital', 'year', 'month', '-received_count'], name='appreciatio_hospita_a0d454_idx'), + ), + migrations.AddIndex( + model_name='appreciationstats', + index=models.Index(fields=['department', 'year', 'month', '-received_count'], name='appreciatio_departm_f68345_idx'), + ), + migrations.AlterUniqueTogether( + name='appreciationstats', + unique_together={('recipient_content_type', 'recipient_object_id', 'year', 'month')}, + ), + migrations.AddIndex( + model_name='userbadge', + index=models.Index(fields=['recipient_content_type', 'recipient_object_id', '-earned_at'], name='appreciatio_recipie_fc90c8_idx'), + ), + ] diff --git a/apps/callcenter/migrations/0001_initial.py b/apps/callcenter/migrations/0001_initial.py new file mode 100644 index 0000000..acf3a1f --- /dev/null +++ b/apps/callcenter/migrations/0001_initial.py @@ -0,0 +1,91 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('organizations', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CallCenterInteraction', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('caller_name', models.CharField(blank=True, max_length=200)), + ('caller_phone', models.CharField(blank=True, max_length=20)), + ('caller_relationship', models.CharField(choices=[('patient', 'Patient'), ('family', 'Family Member'), ('other', 'Other')], default='patient', max_length=50)), + ('call_type', models.CharField(choices=[('inquiry', 'Inquiry'), ('complaint', 'Complaint'), ('appointment', 'Appointment'), ('follow_up', 'Follow-up'), ('feedback', 'Feedback'), ('other', 'Other')], db_index=True, max_length=50)), + ('subject', models.CharField(max_length=500)), + ('notes', models.TextField(blank=True)), + ('wait_time_seconds', models.IntegerField(blank=True, help_text='Time caller waited before agent answered', null=True)), + ('call_duration_seconds', models.IntegerField(blank=True, help_text='Total call duration', null=True)), + ('satisfaction_rating', models.IntegerField(blank=True, help_text='Caller satisfaction rating (1-5)', null=True)), + ('is_low_rating', models.BooleanField(db_index=True, default=False, help_text='True if rating below threshold (< 3)')), + ('resolved', models.BooleanField(default=False)), + ('resolution_notes', models.TextField(blank=True)), + ('call_started_at', models.DateTimeField(auto_now_add=True)), + ('call_ended_at', models.DateTimeField(blank=True, null=True)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('agent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='call_center_interactions', to=settings.AUTH_USER_MODEL)), + ('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='call_center_interactions', to='organizations.department')), + ('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='call_center_interactions', to='organizations.hospital')), + ('patient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='call_center_interactions', to='organizations.patient')), + ], + options={ + 'ordering': ['-call_started_at'], + 'indexes': [models.Index(fields=['hospital', '-call_started_at'], name='callcenter__hospita_108d22_idx'), models.Index(fields=['agent', '-call_started_at'], name='callcenter__agent_i_51efd4_idx'), models.Index(fields=['is_low_rating', '-call_started_at'], name='callcenter__is_low__cbe9c7_idx')], + }, + ), + migrations.CreateModel( + name='CallRecord', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('media_id', models.UUIDField(help_text='Unique media ID from recording system', unique=True)), + ('media_type', models.CharField(default='Calls', help_text='Type of media (e.g., Calls)', max_length=50)), + ('chain', models.CharField(blank=True, help_text='Chain identifier', max_length=255)), + ('evaluated', models.BooleanField(default=False, help_text='Whether the call has been evaluated')), + ('call_start', models.DateTimeField(help_text='Call start time')), + ('call_end', models.DateTimeField(blank=True, help_text='Call end time', null=True)), + ('call_length', models.CharField(blank=True, help_text='Call length as HH:MM:SS', max_length=20)), + ('call_duration_seconds', models.IntegerField(blank=True, help_text='Call duration in seconds', null=True)), + ('first_name', models.CharField(blank=True, help_text='Caller first name', max_length=100)), + ('last_name', models.CharField(blank=True, help_text='Caller last name', max_length=100)), + ('extension', models.CharField(blank=True, help_text='Extension number', max_length=20)), + ('department', models.CharField(blank=True, help_text='Department name', max_length=255)), + ('location', models.CharField(blank=True, help_text='Location', max_length=255)), + ('inbound_id', models.CharField(blank=True, help_text='Inbound call ID', max_length=50)), + ('inbound_name', models.CharField(blank=True, help_text='Inbound caller name/number', max_length=255)), + ('dnis', models.CharField(blank=True, help_text='Dialed Number Identification Service', max_length=50)), + ('outbound_id', models.CharField(blank=True, help_text='Outbound call ID', max_length=50)), + ('outbound_name', models.CharField(blank=True, help_text='Outbound caller name/number', max_length=255)), + ('flag_name', models.CharField(blank=True, help_text='Flag name', max_length=100)), + ('flag_value', models.CharField(blank=True, help_text='Flag value', max_length=100)), + ('file_location', models.CharField(blank=True, help_text='File system location', max_length=500)), + ('file_name', models.CharField(blank=True, help_text='Recording file name', max_length=500)), + ('file_hash', models.CharField(blank=True, help_text='File hash for integrity', max_length=64)), + ('external_ref', models.CharField(blank=True, help_text='External reference number', max_length=100)), + ('transfer_from', models.CharField(blank=True, help_text='Transfer source', max_length=255)), + ('recorded_by', models.CharField(blank=True, help_text='Recording system/user', max_length=255)), + ('time_zone', models.CharField(default='03:00:00', help_text='Time zone offset', max_length=50)), + ('recording_server_name', models.CharField(blank=True, help_text='Recording server name', max_length=100)), + ('hospital', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='call_records', to='organizations.hospital')), + ], + options={ + 'ordering': ['-call_start'], + 'indexes': [models.Index(fields=['-call_start'], name='callcenter__call_st_75009b_idx'), models.Index(fields=['department'], name='callcenter__departm_94649e_idx'), models.Index(fields=['evaluated'], name='callcenter__evaluat_715b40_idx'), models.Index(fields=['hospital', '-call_start'], name='callcenter__hospita_40f3a7_idx')], + }, + ), + ] diff --git a/apps/complaints/migrations/0001_initial.py b/apps/complaints/migrations/0001_initial.py new file mode 100644 index 0000000..522d82c --- /dev/null +++ b/apps/complaints/migrations/0001_initial.py @@ -0,0 +1,709 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import apps.core.encryption +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('organizations', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ComplaintAdverseAction', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('action_type', models.CharField(choices=[('refused_service', 'Refused Service'), ('delayed_treatment', 'Delayed Treatment'), ('verbal_abuse', 'Verbal Abuse / Hostility'), ('increased_wait', 'Increased Wait Time'), ('unnecessary_procedure', 'Unnecessary Procedure'), ('dismissed_from_care', 'Dismissed from Care'), ('poor_treatment', 'Poor Treatment Quality'), ('discrimination', 'Discrimination'), ('retaliation', 'Retaliation'), ('other', 'Other')], default='other', help_text='Type of adverse action', max_length=30)), + ('severity', models.CharField(choices=[('low', 'Low - Minor inconvenience'), ('medium', 'Medium - Moderate impact'), ('high', 'High - Significant harm'), ('critical', 'Critical - Severe harm / Life-threatening')], default='medium', help_text='Severity level of the adverse action', max_length=10)), + ('description', models.TextField(help_text='Detailed description of what happened to the patient')), + ('incident_date', models.DateTimeField(help_text='Date and time when the adverse action occurred')), + ('location', models.CharField(blank=True, help_text='Location where the incident occurred (e.g., Emergency Room, Clinic B)', max_length=200)), + ('patient_impact', models.TextField(blank=True, help_text='Description of the impact on the patient (physical, emotional, financial)')), + ('status', models.CharField(choices=[('reported', 'Reported - Awaiting Review'), ('under_investigation', 'Under Investigation'), ('verified', 'Verified'), ('unfounded', 'Unfounded'), ('resolved', 'Resolved')], default='reported', help_text='Current status of the adverse action report', max_length=30)), + ('investigation_notes', models.TextField(blank=True, help_text='Notes from the investigation')), + ('investigated_at', models.DateTimeField(blank=True, help_text='When the investigation was completed', null=True)), + ('resolution', models.TextField(blank=True, help_text='How the adverse action was resolved')), + ('resolved_at', models.DateTimeField(blank=True, help_text='When the adverse action was resolved', null=True)), + ('is_escalated', models.BooleanField(default=False, help_text='Whether this adverse action has been escalated to management')), + ('escalated_at', models.DateTimeField(blank=True, help_text='When the adverse action was escalated', null=True)), + ], + options={ + 'verbose_name': 'Complaint Adverse Action', + 'verbose_name_plural': 'Complaint Adverse Actions', + 'ordering': ['-incident_date', '-created_at'], + }, + ), + migrations.CreateModel( + name='ComplaintAdverseActionAttachment', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('file', models.FileField(help_text='Attachment file (image, document, audio recording, etc.)', upload_to='complaints/adverse_actions/%Y/%m/%d/')), + ('filename', models.CharField(max_length=255)), + ('file_type', models.CharField(blank=True, max_length=100)), + ('file_size', models.IntegerField(help_text='File size in bytes')), + ('description', models.TextField(blank=True, help_text='Description of what this attachment shows')), + ], + options={ + 'verbose_name': 'Adverse Action Attachment', + 'verbose_name_plural': 'Adverse Action Attachments', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ComplaintAttachment', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('file', models.FileField(upload_to='complaints/%Y/%m/%d/')), + ('filename', models.CharField(max_length=500)), + ('file_type', models.CharField(blank=True, max_length=100)), + ('file_size', models.IntegerField(help_text='File size in bytes')), + ('description', models.TextField(blank=True)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ComplaintCategory', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('code', models.CharField(help_text='Unique code for this category', max_length=100)), + ('name_en', models.CharField(max_length=200)), + ('name_ar', models.CharField(blank=True, max_length=200)), + ('description_en', models.TextField(blank=True)), + ('description_ar', models.TextField(blank=True)), + ('level', models.IntegerField(choices=[(1, 'Domain'), (2, 'Category'), (3, 'Subcategory'), (4, 'Classification')], help_text='Hierarchy level (1=Domain, 2=Category, 3=Subcategory, 4=Classification)')), + ('domain_type', models.CharField(blank=True, choices=[('CLINICAL', 'Clinical'), ('MANAGEMENT', 'Management'), ('RELATIONSHIPS', 'Relationships')], help_text='Domain type for top-level categories', max_length=20)), + ('order', models.IntegerField(default=0, help_text='Display order')), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'verbose_name_plural': 'Complaint Categories', + 'ordering': ['order', 'name_en'], + }, + ), + migrations.CreateModel( + name='ComplaintCommunication', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('communication_type', models.CharField(choices=[('phone_call', 'Phone Call'), ('email', 'Email'), ('sms', 'SMS'), ('meeting', 'Meeting'), ('letter', 'Letter'), ('other', 'Other')], help_text='Type of communication', max_length=20)), + ('direction', models.CharField(choices=[('inbound', 'Inbound'), ('outbound', 'Outbound')], help_text='Direction of communication', max_length=20)), + ('contacted_person', models.CharField(help_text='Name of person contacted', max_length=200)), + ('contacted_role', models.CharField(blank=True, help_text='Role/relation (e.g., Complainant, Patient, Staff)', max_length=100)), + ('contacted_phone', models.CharField(blank=True, help_text='Phone number', max_length=20)), + ('contacted_email', models.EmailField(blank=True, help_text='Email address', max_length=254)), + ('subject', models.CharField(blank=True, help_text='Subject/summary of communication', max_length=500)), + ('notes', models.TextField(help_text='Details of what was discussed')), + ('requires_followup', models.BooleanField(default=False, help_text='Whether this communication requires follow-up')), + ('followup_date', models.DateField(blank=True, help_text='Date when follow-up is needed', null=True)), + ('followup_notes', models.TextField(blank=True, help_text='Notes from follow-up')), + ('attachment', models.FileField(blank=True, help_text='Attached document (email export, letter, etc.)', null=True, upload_to='complaints/communications/%Y/%m/%d/')), + ], + options={ + 'verbose_name': 'Complaint Communication', + 'verbose_name_plural': 'Complaint Communications', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ComplaintExplanation', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('explanation', models.TextField(help_text="Staff's explanation about the complaint")), + ('token', models.CharField(help_text='Unique access token for explanation submission', max_length=64, unique=True)), + ('is_used', models.BooleanField(db_index=True, default=False, help_text='Token expiry tracking - becomes True after submission')), + ('submitted_via', models.CharField(choices=[('email_link', 'Email Link'), ('direct', 'Direct Entry')], default='email_link', help_text='How the explanation was submitted', max_length=20)), + ('email_sent_at', models.DateTimeField(blank=True, help_text='When the explanation request email was sent', null=True)), + ('responded_at', models.DateTimeField(blank=True, help_text='When the explanation was submitted', null=True)), + ('request_message', models.TextField(blank=True, help_text='Optional message sent with the explanation request')), + ('sla_due_at', models.DateTimeField(blank=True, db_index=True, help_text='SLA deadline for staff to submit explanation', null=True)), + ('is_overdue', models.BooleanField(db_index=True, default=False, help_text='Explanation request is overdue')), + ('reminder_sent_at', models.DateTimeField(blank=True, help_text='First reminder sent to staff about overdue explanation', null=True)), + ('second_reminder_sent_at', models.DateTimeField(blank=True, help_text='Second reminder sent to staff about overdue explanation', null=True)), + ('escalated_at', models.DateTimeField(blank=True, help_text='When explanation was escalated to manager', null=True)), + ('acceptance_status', models.CharField(choices=[('pending', 'Pending Review'), ('acceptable', 'Acceptable'), ('not_acceptable', 'Not Acceptable')], default='pending', help_text='Review status of the explanation', max_length=20)), + ('accepted_at', models.DateTimeField(blank=True, help_text='When the explanation was reviewed', null=True)), + ('acceptance_notes', models.TextField(blank=True, help_text='Notes about the acceptance decision')), + ], + options={ + 'verbose_name': 'Complaint Explanation', + 'verbose_name_plural': 'Complaint Explanations', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ComplaintInvolvedDepartment', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('role', models.CharField(choices=[('primary', 'Primary Department'), ('secondary', 'Secondary/Supporting'), ('coordination', 'Coordination Only'), ('investigating', 'Investigating')], default='secondary', help_text='Role of this department in the complaint resolution', max_length=20)), + ('is_primary', models.BooleanField(default=False, help_text='Mark as the primary responsible department')), + ('notes', models.TextField(blank=True, help_text="Additional notes about this department's involvement")), + ('assigned_at', models.DateTimeField(blank=True, null=True)), + ('response_submitted', models.BooleanField(default=False, help_text='Whether this department has submitted their response')), + ('response_submitted_at', models.DateTimeField(blank=True, null=True)), + ('response_notes', models.TextField(blank=True, help_text="Department's response/feedback on the complaint")), + ('forwarded_at', models.DateTimeField(blank=True, help_text='When complaint was sent to this department', null=True)), + ('first_reminder_sent_at', models.DateTimeField(blank=True, help_text='When first reminder was sent to this department', null=True)), + ('second_reminder_sent_at', models.DateTimeField(blank=True, help_text='When second reminder was sent to this department', null=True)), + ('delay_reason', models.TextField(blank=True, help_text='Reason for department delay in response')), + ('delayed_person', models.CharField(blank=True, help_text='Name of person responsible for delay', max_length=200)), + ], + options={ + 'verbose_name': 'Complaint Involved Department', + 'verbose_name_plural': 'Complaint Involved Departments', + 'ordering': ['-is_primary', '-created_at'], + }, + ), + migrations.CreateModel( + name='ComplaintInvolvedStaff', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('role', models.CharField(choices=[('accused', 'Accused/Involved'), ('witness', 'Witness'), ('responsible', 'Responsible for Resolution'), ('investigator', 'Investigator'), ('support', 'Support Staff'), ('px_management', 'PX Management')], default='accused', help_text='Role of this staff member in the complaint', max_length=20)), + ('notes', models.TextField(blank=True, help_text="Additional notes about this staff member's involvement")), + ('explanation_requested', models.BooleanField(default=False, help_text='Whether an explanation has been requested from this staff')), + ('explanation_requested_at', models.DateTimeField(blank=True, null=True)), + ('explanation_received', models.BooleanField(default=False, help_text='Whether an explanation has been received')), + ('explanation_received_at', models.DateTimeField(blank=True, null=True)), + ('explanation', models.TextField(blank=True, help_text="The staff member's explanation")), + ], + options={ + 'verbose_name': 'Complaint Involved Staff', + 'verbose_name_plural': 'Complaint Involved Staff', + 'ordering': ['role', '-created_at'], + }, + ), + migrations.CreateModel( + name='ComplaintMeeting', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('meeting_date', models.DateTimeField(help_text='Date and time of the meeting')), + ('meeting_type', models.CharField(choices=[('management_intervention', 'Management Intervention'), ('pr_follow_up', 'PR Follow-up'), ('department_review', 'Department Review'), ('other', 'Other')], default='management_intervention', help_text='Type of meeting', max_length=50)), + ('outcome', models.TextField(blank=True, help_text='Meeting outcome and agreed resolution')), + ('notes', models.TextField(blank=True, help_text='Additional meeting notes')), + ], + options={ + 'verbose_name': 'Complaint Meeting', + 'verbose_name_plural': 'Complaint Meetings', + 'ordering': ['-meeting_date'], + }, + ), + migrations.CreateModel( + name='ComplaintPRInteraction', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('contact_date', models.DateTimeField(help_text='Date and time of PR contact with complainant')), + ('contact_method', models.CharField(choices=[('phone', 'Phone'), ('in_person', 'In Person'), ('email', 'Email'), ('other', 'Other')], default='in_person', help_text='Method of contact', max_length=20)), + ('statement_text', models.TextField(blank=True, help_text='Formal statement taken from the complainant')), + ('procedure_explained', models.BooleanField(default=False, help_text='Whether complaint procedure was explained to the complainant')), + ('notes', models.TextField(blank=True, help_text='Additional notes from the PR interaction')), + ], + options={ + 'verbose_name': 'PR Interaction', + 'verbose_name_plural': 'PR Interactions', + 'ordering': ['-contact_date'], + }, + ), + migrations.CreateModel( + name='ComplaintSLAConfig', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('severity', models.CharField(blank=True, choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], help_text='Severity level for this SLA (optional if source is specified)', max_length=20, null=True)), + ('priority', models.CharField(blank=True, choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], help_text='Priority level for this SLA (optional if source is specified)', max_length=20, null=True)), + ('sla_hours', models.IntegerField(help_text='Number of hours until SLA deadline')), + ('first_reminder_hours_after', models.IntegerField(default=0, help_text='Send 1st reminder X hours after complaint creation (0 = use reminder_hours_before)')), + ('second_reminder_hours_after', models.IntegerField(default=0, help_text='Send 2nd reminder X hours after complaint creation (0 = use second_reminder_hours_before)')), + ('escalation_hours_after', models.IntegerField(default=0, help_text='Escalate complaint X hours after creation if unresolved (0 = use overdue logic)')), + ('reminder_hours_before', models.IntegerField(default=24, help_text='Send first reminder X hours before deadline')), + ('second_reminder_enabled', models.BooleanField(default=False, help_text='Enable sending a second reminder')), + ('second_reminder_hours_before', models.IntegerField(default=6, help_text='Send second reminder X hours before deadline')), + ('thank_you_email_enabled', models.BooleanField(default=False, help_text='Send thank you email when complaint is closed')), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'ordering': ['hospital', 'source', 'severity', 'priority'], + }, + ), + migrations.CreateModel( + name='ComplaintTemplate', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(help_text="Template name (e.g., 'Long Wait Time', 'Rude Staff')", max_length=200)), + ('description', models.TextField(help_text='Default description template with placeholders')), + ('default_severity', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], default='medium', help_text='Default severity level', max_length=20)), + ('default_priority', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], default='medium', help_text='Default priority level', max_length=20)), + ('usage_count', models.IntegerField(default=0, editable=False, help_text='Number of times this template has been used')), + ('placeholders', models.JSONField(blank=True, default=list, help_text='List of placeholder names used in description')), + ('is_active', models.BooleanField(db_index=True, default=True, help_text='Whether this template is available for selection')), + ], + options={ + 'verbose_name': 'Complaint Template', + 'verbose_name_plural': 'Complaint Templates', + 'ordering': ['-usage_count', 'name'], + }, + ), + migrations.CreateModel( + name='ComplaintThreshold', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('threshold_type', models.CharField(choices=[('resolution_survey_score', 'Resolution Survey Score'), ('response_time', 'Response Time'), ('resolution_time', 'Resolution Time')], help_text='Type of threshold', max_length=50)), + ('threshold_value', models.FloatField(help_text='Threshold value (e.g., 50 for 50% score)')), + ('comparison_operator', models.CharField(choices=[('lt', 'Less Than'), ('lte', 'Less Than or Equal'), ('gt', 'Greater Than'), ('gte', 'Greater Than or Equal'), ('eq', 'Equal')], default='lt', help_text='How to compare against threshold', max_length=10)), + ('action_type', models.CharField(choices=[('create_px_action', 'Create PX Action'), ('send_notification', 'Send Notification'), ('escalate', 'Escalate')], help_text='Action to take when threshold is breached', max_length=50)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'ordering': ['hospital', 'threshold_type'], + }, + ), + migrations.CreateModel( + name='ComplaintUpdate', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('update_type', models.CharField(choices=[('status_change', 'Status Change'), ('assignment', 'Assignment'), ('note', 'Note'), ('resolution', 'Resolution'), ('escalation', 'Escalation'), ('communication', 'Communication')], db_index=True, max_length=50)), + ('message', models.TextField()), + ('old_status', models.CharField(blank=True, max_length=25)), + ('new_status', models.CharField(blank=True, max_length=25)), + ('metadata', models.JSONField(blank=True, default=dict)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='EscalationRule', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200)), + ('description', models.TextField(blank=True)), + ('escalation_level', models.IntegerField(default=1, help_text='Escalation level (1 = first level, 2 = second, etc.)')), + ('max_escalation_level', models.IntegerField(default=3, help_text='Maximum escalation level before stopping (default: 3)')), + ('trigger_on_overdue', models.BooleanField(default=True, help_text='Trigger when complaint is overdue')), + ('trigger_hours_overdue', models.IntegerField(default=0, help_text='Trigger X hours after overdue (0 = immediately)')), + ('reminder_escalation_enabled', models.BooleanField(default=False, help_text='Enable escalation after reminder if no action taken')), + ('reminder_escalation_hours', models.IntegerField(default=24, help_text='Escalate X hours after reminder if no action')), + ('escalate_to_role', models.CharField(choices=[('department_manager', 'Department Manager'), ('hospital_admin', 'Hospital Admin'), ('medical_director', 'Medical Director'), ('admin_director', 'Administrative Director'), ('px_admin', 'PX Admin'), ('ceo', 'CEO'), ('specific_user', 'Specific User')], help_text='Role to escalate to', max_length=50)), + ('severity_filter', models.CharField(blank=True, choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], help_text='Only escalate complaints with this severity (blank = all)', max_length=20)), + ('priority_filter', models.CharField(blank=True, choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], help_text='Only escalate complaints with this priority (blank = all)', max_length=20)), + ('order', models.IntegerField(default=0, help_text='Escalation order (lower = first)')), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'ordering': ['hospital', 'order'], + }, + ), + migrations.CreateModel( + name='ExplanationAttachment', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('file', models.FileField(upload_to='explanation_attachments/%Y/%m/%d/')), + ('filename', models.CharField(max_length=500)), + ('file_type', models.CharField(blank=True, max_length=100)), + ('file_size', models.IntegerField(help_text='File size in bytes')), + ('description', models.TextField(blank=True)), + ], + options={ + 'verbose_name': 'Explanation Attachment', + 'verbose_name_plural': 'Explanation Attachments', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ExplanationSLAConfig', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('response_hours', models.IntegerField(default=48, help_text='Hours staff has to submit explanation')), + ('reminder_hours_before', models.IntegerField(default=12, help_text='Send first reminder X hours before deadline')), + ('second_reminder_enabled', models.BooleanField(default=True, help_text='Enable sending a second reminder before escalation')), + ('second_reminder_hours_before', models.IntegerField(default=4, help_text='Send second reminder X hours before deadline')), + ('auto_escalate_enabled', models.BooleanField(default=True, help_text='Automatically escalate to manager if no response')), + ('escalation_hours_overdue', models.IntegerField(default=0, help_text='Escalate X hours after overdue (0 = immediately)')), + ('max_escalation_levels', models.IntegerField(default=3, help_text='Maximum levels to escalate up staff hierarchy')), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'verbose_name': 'Explanation SLA Config', + 'verbose_name_plural': 'Explanation SLA Configs', + 'ordering': ['hospital'], + }, + ), + migrations.CreateModel( + name='GovernmentTicket', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('ticket_number', models.CharField(db_index=True, help_text='Ticket number from source system (e.g., B2022807)', max_length=50, unique=True)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed')], db_index=True, default='pending', max_length=20)), + ('complainant_name', models.CharField(max_length=200)), + ('national_id', models.CharField(blank=True, max_length=20)), + ('contact_number', models.CharField(blank=True, max_length=20)), + ('received_date', models.DateTimeField(help_text='Date/time the ticket was received from source')), + ('classification', models.CharField(blank=True, max_length=100)), + ('content', models.TextField()), + ('converted_to_complaint', models.BooleanField(db_index=True, default=False)), + ], + options={ + 'verbose_name': 'Government Ticket', + 'verbose_name_plural': 'Government Tickets', + 'ordering': ['-received_date'], + }, + ), + migrations.CreateModel( + name='Inquiry', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('is_deleted', models.BooleanField(db_index=True, default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('contact_name', models.CharField(blank=True, max_length=200)), + ('contact_phone', models.CharField(blank=True, max_length=20)), + ('contact_email', models.EmailField(blank=True, max_length=254)), + ('reference_number', models.CharField(blank=True, max_length=50, unique=True)), + ('subject', models.CharField(max_length=500)), + ('message', models.TextField()), + ('category', models.CharField(choices=[('appointment', 'Appointment'), ('billing', 'Billing'), ('medical_records', 'Medical Records'), ('general', 'General Information'), ('other', 'Other')], max_length=100)), + ('priority', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High')], db_index=True, default='low', max_length=20)), + ('ai_brief_en', models.CharField(blank=True, help_text='AI-generated brief summary (English)', max_length=500)), + ('ai_brief_ar', models.CharField(blank=True, help_text='AI-generated brief summary (Arabic)', max_length=500)), + ('status', models.CharField(choices=[('open', 'Open'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed'), ('contacted', 'Contacted'), ('contacted_no_response', 'Contacted, No Response')], db_index=True, default='open', max_length=25)), + ('assigned_at', models.DateTimeField(blank=True, null=True)), + ('activated_at', models.DateTimeField(blank=True, db_index=True, help_text='Timestamp when inquiry was first activated (moved from OPEN to IN_PROGRESS)', null=True)), + ('due_at', models.DateTimeField(blank=True, db_index=True, help_text='SLA deadline', null=True)), + ('is_overdue', models.BooleanField(db_index=True, default=False)), + ('breached_at', models.DateTimeField(blank=True, db_index=True, help_text='Timestamp when inquiry first breached SLA', null=True)), + ('reminder_sent_at', models.DateTimeField(blank=True, help_text='First SLA reminder timestamp', null=True)), + ('second_reminder_sent_at', models.DateTimeField(blank=True, help_text='Second SLA reminder timestamp', null=True)), + ('escalated_at', models.DateTimeField(blank=True, null=True)), + ('response', models.TextField(blank=True)), + ('response_en', models.TextField(blank=True, help_text='Response text (English)')), + ('response_ar', models.TextField(blank=True, help_text='Response text (Arabic)')), + ('response_sent_at', models.DateTimeField(blank=True, help_text='When response was sent to inquirer', null=True)), + ('responded_at', models.DateTimeField(blank=True, null=True)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('is_straightforward', models.BooleanField(default=True, help_text='Direct resolution (no department coordination needed)', verbose_name='Is Straightforward')), + ('is_outgoing', models.BooleanField(default=False, help_text='Inquiry sent to external department for response', verbose_name='Is Outgoing')), + ('transferred_at', models.DateTimeField(blank=True, db_index=True, help_text='When the inquiry was transferred to a department', null=True)), + ('transfer_count', models.PositiveIntegerField(default=0, help_text='Number of times this inquiry has been transferred')), + ('department_response_en', models.TextField(blank=True, verbose_name='Department Response (English)')), + ('department_response_ar', models.TextField(blank=True, verbose_name='Department Response (Arabic)')), + ('department_response_summary_en', models.TextField(blank=True, verbose_name='AI Summary of Dept Response (EN)')), + ('department_response_summary_ar', models.TextField(blank=True, verbose_name='AI Summary of Dept Response (AR)')), + ('department_responded_at', models.DateTimeField(blank=True, null=True)), + ('dept_response_sla_due_at', models.DateTimeField(blank=True, db_index=True, help_text='SLA deadline for department response', null=True)), + ('dept_response_is_overdue', models.BooleanField(db_index=True, default=False, help_text='Department response is overdue')), + ('dept_response_reminder_sent_at', models.DateTimeField(blank=True, help_text='First SLA reminder for dept response', null=True)), + ('dept_response_second_reminder_sent_at', models.DateTimeField(blank=True, help_text='Second SLA reminder for dept response', null=True)), + ('dept_response_escalated_at', models.DateTimeField(blank=True, help_text='When dept response was escalated to manager', null=True)), + ('dept_response_acceptance_status', models.CharField(choices=[('pending', 'Pending Review'), ('acceptable', 'Acceptable'), ('not_acceptable', 'Not Acceptable')], default='pending', help_text='Review status of the department response', max_length=20)), + ('dept_response_accepted_at', models.DateTimeField(blank=True, help_text='When the department response was reviewed', null=True)), + ('dept_response_acceptance_notes', models.TextField(blank=True, help_text='Notes about the acceptance decision')), + ('contacted_nr_at', models.DateTimeField(blank=True, null=True, verbose_name='Contacted NR Date')), + ('contacted_nr_time', models.TimeField(blank=True, null=True, verbose_name='Contacted NR Time')), + ('contacted_nr_duration', models.DurationField(blank=True, null=True, verbose_name='Contacted NR Duration')), + ('under_process_at', models.DateTimeField(blank=True, null=True, verbose_name='Under Process Date')), + ('under_process_time', models.TimeField(blank=True, null=True, verbose_name='Under Process Time')), + ('under_process_duration', models.DurationField(blank=True, null=True, verbose_name='Under Process Duration')), + ('contacted_at', models.DateTimeField(blank=True, null=True, verbose_name='Contacted Date')), + ('contacted_time', models.TimeField(blank=True, null=True, verbose_name='Contacted Time')), + ('contacted_duration', models.DurationField(blank=True, null=True, verbose_name='Contacted Duration')), + ('timeline_sla', models.CharField(blank=True, choices=[('24_hours', '24 Hours'), ('48_hours', '48 Hours'), ('72_hours', '72 Hours'), ('more_than_72_hours', 'More than 72 hours')], help_text='Response deadline category', max_length=50, verbose_name='Timeline SLA')), + ('staff_notes', models.TextField(blank=True, verbose_name='Staff Notes')), + ('supervisor_notes', models.TextField(blank=True, verbose_name='Supervisor Notes')), + ('requires_follow_up', models.BooleanField(db_index=True, default=False, help_text='This inquiry requires follow-up call')), + ('follow_up_due_at', models.DateTimeField(blank=True, db_index=True, help_text='Due date for follow-up call to inquirer', null=True)), + ('follow_up_completed_at', models.DateTimeField(blank=True, help_text='When follow-up call was completed', null=True)), + ('follow_up_notes', models.TextField(blank=True, help_text='Notes from follow-up call')), + ('follow_up_reminder_sent_at', models.DateTimeField(blank=True, help_text='When reminder was sent for follow-up', null=True)), + ], + options={ + 'verbose_name_plural': 'Inquiries', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='InquiryAttachment', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('file', models.FileField(upload_to='inquiries/%Y/%m/%d/')), + ('filename', models.CharField(max_length=500)), + ('file_type', models.CharField(blank=True, max_length=100)), + ('file_size', models.IntegerField(help_text='File size in bytes')), + ('description', models.TextField(blank=True)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='InquiryExplanation', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('explanation', models.TextField(blank=True, help_text="Staff's response to the inquiry")), + ('token', models.CharField(help_text='Unique access token for response submission', max_length=64, unique=True)), + ('is_used', models.BooleanField(db_index=True, default=False, help_text='Token expiry tracking - becomes True after submission')), + ('submitted_via', models.CharField(choices=[('email_link', 'Email Link'), ('direct', 'Direct Entry')], default='email_link', max_length=20)), + ('email_sent_at', models.DateTimeField(blank=True, help_text='When the response request email was sent', null=True)), + ('responded_at', models.DateTimeField(blank=True, help_text='When the response was submitted', null=True)), + ('request_message', models.TextField(blank=True, help_text='Optional message sent with the response request')), + ('sla_due_at', models.DateTimeField(blank=True, db_index=True, help_text='SLA deadline for staff to submit response', null=True)), + ('is_overdue', models.BooleanField(db_index=True, default=False)), + ('reminder_sent_at', models.DateTimeField(blank=True, null=True)), + ('second_reminder_sent_at', models.DateTimeField(blank=True, null=True)), + ('escalated_at', models.DateTimeField(blank=True, null=True)), + ], + options={ + 'verbose_name': 'Inquiry Explanation', + 'verbose_name_plural': 'Inquiry Explanations', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='InquiryExplanationAttachment', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('file', models.FileField(upload_to='inquiry_explanation_attachments/%Y/%m/%d/')), + ('filename', models.CharField(max_length=500)), + ('file_type', models.CharField(blank=True, max_length=100)), + ('file_size', models.IntegerField(help_text='File size in bytes')), + ('description', models.TextField(blank=True)), + ], + options={ + 'verbose_name': 'Inquiry Explanation Attachment', + 'verbose_name_plural': 'Inquiry Explanation Attachments', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='InquirySLAConfig', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('sla_hours', models.IntegerField(help_text='Number of hours until SLA deadline')), + ('first_reminder_hours_after', models.IntegerField(default=0, help_text='Send 1st reminder X hours after inquiry creation (0 = use reminder_hours_before)')), + ('second_reminder_hours_after', models.IntegerField(default=0, help_text='Send 2nd reminder X hours after inquiry creation (0 = use second_reminder_hours_before)')), + ('escalation_hours_after', models.IntegerField(default=0, help_text='Escalate inquiry X hours after creation if unresolved (0 = use overdue logic)')), + ('reminder_hours_before', models.IntegerField(default=24, help_text='Send first reminder X hours before deadline')), + ('second_reminder_enabled', models.BooleanField(default=False, help_text='Enable sending a second reminder')), + ('second_reminder_hours_before', models.IntegerField(default=6, help_text='Send second reminder X hours before deadline')), + ('is_active', models.BooleanField(default=True)), + ('dept_response_hours', models.IntegerField(default=48, help_text='Hours for department to submit a response')), + ('dept_response_reminder_hours_before', models.IntegerField(default=12, help_text='Send 1st reminder X hours before dept response deadline')), + ('dept_response_second_reminder_enabled', models.BooleanField(default=True, help_text='Enable sending a second reminder for dept response')), + ('dept_response_second_reminder_hours_before', models.IntegerField(default=4, help_text='Send 2nd reminder X hours before dept response deadline')), + ('dept_response_auto_escalate_enabled', models.BooleanField(default=True, help_text='Auto-escalate to department manager if response overdue')), + ('dept_response_escalation_hours_overdue', models.IntegerField(default=0, help_text='Escalate X hours after dept response deadline (0 = immediately)')), + ], + options={ + 'verbose_name': 'Inquiry SLA Config', + 'verbose_name_plural': 'Inquiry SLA Configs', + 'ordering': ['hospital', 'source'], + }, + ), + migrations.CreateModel( + name='InquiryUpdate', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('update_type', models.CharField(choices=[('status_change', 'Status Change'), ('assignment', 'Assignment'), ('note', 'Note'), ('response', 'Response'), ('communication', 'Communication'), ('department_response', 'Department Response'), ('sent_to_department', 'Sent to Department'), ('transferred_to_department', 'Transferred to Department')], db_index=True, max_length=50)), + ('message', models.TextField()), + ('old_status', models.CharField(blank=True, max_length=25)), + ('new_status', models.CharField(blank=True, max_length=25)), + ('metadata', models.JSONField(blank=True, default=dict)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='OnCallAdmin', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('start_date', models.DateField(blank=True, help_text='Start date for this on-call assignment (optional)', null=True)), + ('end_date', models.DateField(blank=True, help_text='End date for this on-call assignment (optional)', null=True)), + ('notification_priority', models.PositiveIntegerField(default=1, help_text='Priority for notifications (1 = highest)')), + ('is_active', models.BooleanField(default=True, help_text='Whether this on-call assignment is currently active')), + ('notify_email', models.BooleanField(default=True, help_text='Send email notifications')), + ('notify_sms', models.BooleanField(default=False, help_text='Send SMS notifications')), + ('sms_phone', models.CharField(blank=True, help_text='Custom phone number for SMS notifications (optional)', max_length=20)), + ], + options={ + 'verbose_name': 'On-Call Admin', + 'verbose_name_plural': 'On-Call Admins', + 'ordering': ['notification_priority', '-created_at'], + }, + ), + migrations.CreateModel( + name='OnCallAdminSchedule', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('working_days', models.JSONField(default=list, help_text='List of working days (0=Monday, 6=Sunday). Default: [0,1,2,3,4] (Mon-Fri)')), + ('work_start_time', models.TimeField(default='08:00', help_text='Start of working hours (e.g., 08:00)')), + ('work_end_time', models.TimeField(default='17:00', help_text='End of working hours (e.g., 17:00)')), + ('timezone', models.CharField(default='Asia/Riyadh', help_text='Timezone for working hours calculation (e.g., Asia/Riyadh)', max_length=50)), + ('is_active', models.BooleanField(default=True, help_text='Whether this on-call schedule is active')), + ], + options={ + 'verbose_name': 'On-Call Admin Schedule', + 'verbose_name_plural': 'On-Call Admin Schedules', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='PatientComplaintSession', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('token', models.CharField(max_length=64, unique=True)), + ('expires_at', models.DateTimeField(db_index=True)), + ('is_active', models.BooleanField(db_index=True, default=True)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='Complaint', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('is_deleted', models.BooleanField(db_index=True, default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('contact_name', models.CharField(blank=True, max_length=200)), + ('contact_phone', models.CharField(blank=True, max_length=20)), + ('contact_email', models.EmailField(blank=True, max_length=254)), + ('relation_to_patient', models.CharField(blank=True, choices=[('patient', 'Patient'), ('relative', 'Relative'), ('friend', 'Friend'), ('other', 'Other')], help_text="Complainant's relationship to the patient", max_length=20, verbose_name='Relation to Patient')), + ('patient_name', models.CharField(blank=True, help_text='Name of the patient involved', max_length=200, verbose_name='Patient Name')), + ('national_id', apps.core.encryption.EncryptedCharField(blank=True, help_text='Saudi National ID or Iqama number', max_length=512, verbose_name='National ID/Iqama No.')), + ('national_id_hash', models.CharField(blank=True, db_index=True, max_length=64)), + ('incident_date', models.DateField(blank=True, help_text='Date when the incident occurred', null=True, verbose_name='Incident Date')), + ('staff_name', models.CharField(blank=True, help_text='Name of staff member involved (if known)', max_length=200, verbose_name='Staff Involved')), + ('expected_result', models.TextField(blank=True, help_text='What the complainant expects as a resolution', verbose_name='Expected Complaint Result')), + ('reference_number', models.CharField(blank=True, help_text='Unique reference number for patient tracking', max_length=50, null=True, unique=True)), + ('encounter_id', models.CharField(blank=True, db_index=True, help_text='Related encounter ID if applicable', max_length=100)), + ('title', models.CharField(max_length=500)), + ('description', models.TextField()), + ('ai_brief_en', models.CharField(blank=True, db_index=True, help_text='AI-generated 2-3 word summary in English', max_length=100)), + ('ai_brief_ar', models.CharField(blank=True, help_text='AI-generated 2-3 word summary in Arabic', max_length=100)), + ('subcategory', models.CharField(blank=True, help_text='Level 3: Subcategory code (legacy)', max_length=100)), + ('classification', models.CharField(blank=True, help_text='Level 4: Classification code (legacy)', max_length=100)), + ('complaint_type', models.CharField(choices=[('complaint', 'Complaint'), ('appreciation', 'Appreciation')], db_index=True, default='complaint', help_text='Type of feedback (complaint vs appreciation)', max_length=20)), + ('complaint_source_type', models.CharField(choices=[('internal', 'Internal'), ('external', 'External')], db_index=True, default='external', help_text='Source type (Internal = staff-generated, External = patient/public-generated)', max_length=20)), + ('priority', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], db_index=True, default='medium', max_length=20)), + ('severity', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], db_index=True, default='medium', max_length=20)), + ('status', models.CharField(choices=[('open', 'Open'), ('in_progress', 'In Progress'), ('partially_resolved', 'Partially Resolved'), ('resolved', 'Resolved'), ('closed', 'Closed'), ('cancelled', 'Cancelled'), ('contacted', 'Contacted'), ('contacted_no_response', 'Contacted, No Response'), ('pending_external', 'Pending External'), ('ovr_pending', 'OVR Pending Approval')], db_index=True, default='open', max_length=25)), + ('assigned_at', models.DateTimeField(blank=True, null=True)), + ('activated_at', models.DateTimeField(blank=True, db_index=True, help_text='Timestamp when complaint was first activated (moved from OPEN to IN_PROGRESS)', null=True)), + ('due_at', models.DateTimeField(db_index=True, help_text='SLA deadline')), + ('is_overdue', models.BooleanField(db_index=True, default=False)), + ('breached_at', models.DateTimeField(blank=True, db_index=True, help_text='Timestamp when complaint first breached SLA', null=True)), + ('reminder_sent_at', models.DateTimeField(blank=True, help_text='First SLA reminder timestamp', null=True)), + ('second_reminder_sent_at', models.DateTimeField(blank=True, help_text='Second SLA reminder timestamp', null=True)), + ('escalated_at', models.DateTimeField(blank=True, null=True)), + ('explanation_requested', models.BooleanField(default=False, help_text='Whether an explanation has been requested from staff')), + ('explanation_requested_at', models.DateTimeField(blank=True, help_text='When explanation request was first sent to staff', null=True)), + ('explanation_received_at', models.DateTimeField(blank=True, help_text='When explanation was received from staff', null=True)), + ('explanation_delay_reason', models.TextField(blank=True, help_text='Reason for delay in receiving staff explanation')), + ('resolution', models.TextField(blank=True)), + ('resolution_sent_at', models.DateTimeField(blank=True, null=True)), + ('resolution_category', models.CharField(blank=True, choices=[('full_action_taken', 'Full Action Taken'), ('partial_action_taken', 'Partial Action Taken'), ('no_action_needed', 'No Action Needed'), ('cannot_resolve', 'Cannot Resolve'), ('patient_withdrawn', 'Patient Withdrawn')], db_index=True, help_text='Category of resolution', max_length=50)), + ('resolution_outcome', models.CharField(blank=True, choices=[('patient', 'Patient'), ('hospital', 'Hospital'), ('other', 'Other — please specify')], db_index=True, help_text='Who was in wrong/right (Patient / Hospital / Other)', max_length=20)), + ('resolution_outcome_other', models.TextField(blank=True, help_text='Specify if Other was selected for resolution outcome')), + ('resolved_at', models.DateTimeField(blank=True, null=True)), + ('closed_at', models.DateTimeField(blank=True, null=True)), + ('reopened_at', models.DateTimeField(blank=True, null=True)), + ('resolution_survey_sent_at', models.DateTimeField(blank=True, null=True)), + ('satisfaction', models.CharField(blank=True, choices=[('satisfied', 'Satisfied'), ('neutral', 'Neutral'), ('dissatisfied', 'Dissatisfied'), ('no_response', 'No Response')], db_index=True, help_text='Direct satisfaction feedback from patient follow-up call', max_length=20)), + ('satisfaction_set_at', models.DateTimeField(blank=True, help_text='When satisfaction was last set', null=True)), + ('moh_reference', models.CharField(blank=True, help_text='Ministry of Health reference number', max_length=100)), + ('moh_reference_date', models.DateField(blank=True, help_text='MOH reference date', null=True)), + ('chi_reference', models.CharField(blank=True, help_text='Council of Health Insurance reference number', max_length=100)), + ('chi_reference_date', models.DateField(blank=True, help_text='CHI reference date', null=True)), + ('file_number', models.CharField(blank=True, db_index=True, help_text='Patient file/MRN number', max_length=100)), + ('form_sent_at', models.DateTimeField(blank=True, help_text='When complaint form was sent to the complained department', null=True)), + ('forwarded_to_dept_at', models.DateTimeField(blank=True, help_text='When complaint was forwarded to the involved department', null=True)), + ('response_date', models.DateField(blank=True, help_text='Date when response was received', null=True)), + ('complaint_subject', models.CharField(blank=True, help_text='Main complaint subject (from Excel classification)', max_length=500)), + ('action_taken_by_dept', models.TextField(blank=True, help_text='Action taken by the responsible department')), + ('action_result', models.TextField(blank=True, help_text='Result of the action/investigation taken')), + ('recommendation_action_plan', models.TextField(blank=True, help_text='Solutions, suggestions, and action plan')), + ('delay_reason_closure', models.CharField(blank=True, choices=[('department_no_response', 'Department No Response'), ('escalated', 'Escalated'), ('patient_not_satisfied', 'Patient Not Satisfied with Complaint Resolution')], help_text='Reason for not closing the complaint within 72 hours', max_length=50)), + ('was_pending_external', models.BooleanField(default=False, help_text='Whether this complaint was ever in pending_external status')), + ('pending_external_set_at', models.DateTimeField(blank=True, help_text='When complaint was set to pending_external status', null=True)), + ('is_escalated_ovr', models.BooleanField(db_index=True, default=False, help_text='Whether complaint is escalated as OVR')), + ('escalated_ovr_at', models.DateTimeField(blank=True, help_text='When OVR escalation was set', null=True)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_complaints', to=settings.AUTH_USER_MODEL)), + ('closed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='closed_complaints', to=settings.AUTH_USER_MODEL)), + ('created_by', models.ForeignKey(blank=True, help_text='User who created this complaint (SourceUser or Patient)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_complaints', to=settings.AUTH_USER_MODEL)), + ('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_%(class)s_set', to=settings.AUTH_USER_MODEL)), + ('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaints', to='organizations.department')), + ('escalated_ovr_by', models.ForeignKey(blank=True, help_text='User who escalated as OVR', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='escalated_ovr_complaints', to=settings.AUTH_USER_MODEL)), + ('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complaints', to='organizations.hospital')), + ('location', models.ForeignKey(blank=True, help_text='Location (e.g., Riyadh, Jeddah)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='complaints', to='organizations.location')), + ('main_section', models.ForeignKey(blank=True, help_text='Section/Department', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='complaints', to='organizations.mainsection')), + ('patient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaints', to='organizations.patient')), + ('reopened_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reopened_complaints', to=settings.AUTH_USER_MODEL)), + ('reopened_from', models.ForeignKey(blank=True, help_text='Original complaint this was reopened from', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reopenings', to='complaints.complaint')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/apps/complaints/migrations/0002_initial.py b/apps/complaints/migrations/0002_initial.py new file mode 100644 index 0000000..89c61dc --- /dev/null +++ b/apps/complaints/migrations/0002_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('complaints', '0001_initial'), + ('surveys', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='complaint', + name='resolution_survey', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaint_resolution', to='surveys.surveyinstance'), + ), + migrations.AddField( + model_name='complaint', + name='resolved_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='resolved_complaints', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/apps/complaints/migrations/0003_initial.py b/apps/complaints/migrations/0003_initial.py new file mode 100644 index 0000000..ec6f9c2 --- /dev/null +++ b/apps/complaints/migrations/0003_initial.py @@ -0,0 +1,678 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('complaints', '0002_initial'), + ('organizations', '0001_initial'), + ('px_sources', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='complaint', + name='source', + field=models.ForeignKey(blank=True, help_text='Source of complaint', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='complaints', to='px_sources.pxsource'), + ), + migrations.AddField( + model_name='complaint', + name='staff', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaints', to='organizations.staff'), + ), + migrations.AddField( + model_name='complaint', + name='subsection', + field=models.ForeignKey(blank=True, help_text='Subsection within the section', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='complaints', to='organizations.subsection'), + ), + migrations.AddField( + model_name='complaintadverseaction', + name='complaint', + field=models.ForeignKey(help_text='The complaint this adverse action is related to', on_delete=django.db.models.deletion.CASCADE, related_name='adverse_actions', to='complaints.complaint'), + ), + migrations.AddField( + model_name='complaintadverseaction', + name='investigated_by', + field=models.ForeignKey(blank=True, help_text='User who investigated this adverse action', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='investigated_adverse_actions', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='complaintadverseaction', + name='involved_staff', + field=models.ManyToManyField(blank=True, help_text='Staff members involved in the adverse action', related_name='adverse_actions_involved', to='organizations.staff'), + ), + migrations.AddField( + model_name='complaintadverseaction', + name='reported_by', + field=models.ForeignKey(blank=True, help_text='User who reported this adverse action', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reported_adverse_actions', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='complaintadverseaction', + name='resolved_by', + field=models.ForeignKey(blank=True, help_text='User who resolved this adverse action', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='resolved_adverse_actions', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='complaintadverseactionattachment', + name='adverse_action', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='complaints.complaintadverseaction'), + ), + migrations.AddField( + model_name='complaintadverseactionattachment', + name='uploaded_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='adverse_action_attachments', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='complaintattachment', + name='complaint', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='complaints.complaint'), + ), + migrations.AddField( + model_name='complaintattachment', + name='uploaded_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaint_attachments', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='complaintcategory', + name='hospitals', + field=models.ManyToManyField(blank=True, help_text='Empty list = system-wide category. Add hospitals to share category.', related_name='complaint_categories', to='organizations.hospital'), + ), + migrations.AddField( + model_name='complaintcategory', + name='parent', + field=models.ForeignKey(blank=True, help_text='Parent category for hierarchical structure', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subcategories', to='complaints.complaintcategory'), + ), + migrations.AddField( + model_name='complaint', + name='category', + field=models.ForeignKey(blank=True, help_text='Level 2: Category', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='complaints', to='complaints.complaintcategory'), + ), + migrations.AddField( + model_name='complaint', + name='classification_obj', + field=models.ForeignKey(blank=True, help_text='Level 4: Classification', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='complaints_classification', to='complaints.complaintcategory'), + ), + migrations.AddField( + model_name='complaint', + name='domain', + field=models.ForeignKey(blank=True, help_text='Level 1: Domain', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='complaints_domain', to='complaints.complaintcategory'), + ), + migrations.AddField( + model_name='complaint', + name='subcategory_obj', + field=models.ForeignKey(blank=True, help_text='Level 3: Subcategory', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='complaints_subcategory', to='complaints.complaintcategory'), + ), + migrations.AddField( + model_name='complaintcommunication', + name='complaint', + field=models.ForeignKey(help_text='Related complaint', on_delete=django.db.models.deletion.CASCADE, related_name='communications', to='complaints.complaint'), + ), + migrations.AddField( + model_name='complaintcommunication', + name='created_by', + field=models.ForeignKey(help_text='User who logged this communication', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaint_communications', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='complaintexplanation', + name='accepted_by', + field=models.ForeignKey(blank=True, help_text='User who reviewed and marked the explanation', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_explanations', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='complaintexplanation', + name='complaint', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='explanations', to='complaints.complaint'), + ), + migrations.AddField( + model_name='complaintexplanation', + name='escalated_to_manager', + field=models.ForeignKey(blank=True, help_text="Escalated to this explanation (manager's explanation request)", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='escalated_from_staff', to='complaints.complaintexplanation'), + ), + migrations.AddField( + model_name='complaintexplanation', + name='requested_by', + field=models.ForeignKey(blank=True, help_text='User who requested the explanation', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requested_complaint_explanations', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='complaintexplanation', + name='staff', + field=models.ForeignKey(blank=True, help_text='Staff member who submitted the explanation', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaint_explanations', to='organizations.staff'), + ), + migrations.AddField( + model_name='complaintinvolveddepartment', + name='added_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='added_department_involvements', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='complaintinvolveddepartment', + name='assigned_to', + field=models.ForeignKey(blank=True, help_text='User assigned from this department to handle the complaint', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='department_assigned_complaints', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='complaintinvolveddepartment', + name='complaint', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='involved_departments', to='complaints.complaint'), + ), + migrations.AddField( + model_name='complaintinvolveddepartment', + name='department', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complaint_involvements', to='organizations.department'), + ), + migrations.AddField( + model_name='complaintinvolvedstaff', + name='added_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='added_staff_involvements', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='complaintinvolvedstaff', + name='complaint', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='involved_staff', to='complaints.complaint'), + ), + migrations.AddField( + model_name='complaintinvolvedstaff', + name='staff', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complaint_involvements', to='organizations.staff'), + ), + migrations.AddField( + model_name='complaintmeeting', + name='complaint', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meetings', to='complaints.complaint'), + ), + migrations.AddField( + model_name='complaintmeeting', + name='created_by', + field=models.ForeignKey(blank=True, help_text='User who created this meeting record', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_meetings', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='complaintprinteraction', + name='complaint', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pr_interactions', to='complaints.complaint'), + ), + migrations.AddField( + model_name='complaintprinteraction', + name='created_by', + field=models.ForeignKey(blank=True, help_text='User who created this PR interaction record', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_pr_interactions', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='complaintprinteraction', + name='pr_staff', + field=models.ForeignKey(blank=True, help_text='PR staff member who made the contact', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pr_interactions', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='complaintslaconfig', + name='hospital', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complaint_sla_configs', to='organizations.hospital'), + ), + migrations.AddField( + model_name='complaintslaconfig', + name='source', + field=models.ForeignKey(blank=True, help_text='Complaint source (MOH, CHI, Patient, etc.). Empty = severity/priority-based config', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaint_sla_configs', to='px_sources.pxsource'), + ), + migrations.AddField( + model_name='complainttemplate', + name='auto_assign_department', + field=models.ForeignKey(blank=True, help_text='Auto-assign to this department when template is used', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='template_assignments', to='organizations.department'), + ), + migrations.AddField( + model_name='complainttemplate', + name='category', + field=models.ForeignKey(blank=True, help_text='Default category for this template', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='templates', to='complaints.complaintcategory'), + ), + migrations.AddField( + model_name='complainttemplate', + name='hospital', + field=models.ForeignKey(help_text='Hospital this template belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='complaint_templates', to='organizations.hospital'), + ), + migrations.AddField( + model_name='complaintthreshold', + name='hospital', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complaint_thresholds', to='organizations.hospital'), + ), + migrations.AddField( + model_name='complaintupdate', + name='complaint', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='updates', to='complaints.complaint'), + ), + migrations.AddField( + model_name='complaintupdate', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaint_updates', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='escalationrule', + name='escalate_to_user', + field=models.ForeignKey(blank=True, help_text="Specific user if escalate_to_role is 'specific_user'", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='escalation_target_rules', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='escalationrule', + name='hospital', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='escalation_rules', to='organizations.hospital'), + ), + migrations.AddField( + model_name='explanationattachment', + name='explanation', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='complaints.complaintexplanation'), + ), + migrations.AddField( + model_name='explanationslaconfig', + name='hospital', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='explanation_sla_configs', to='organizations.hospital'), + ), + migrations.AddField( + model_name='governmentticket', + name='assigned_to', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_government_tickets', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='governmentticket', + name='complaint', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='government_ticket', to='complaints.complaint'), + ), + migrations.AddField( + model_name='governmentticket', + name='location', + field=models.ForeignKey(blank=True, help_text='Location (e.g., Riyadh, Jeddah)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='government_tickets', to='organizations.location'), + ), + migrations.AddField( + model_name='governmentticket', + name='main_section', + field=models.ForeignKey(blank=True, help_text='Section/Department', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='government_tickets', to='organizations.mainsection'), + ), + migrations.AddField( + model_name='governmentticket', + name='source', + field=models.ForeignKey(help_text='Government source (MOH, CCHI, etc.)', limit_choices_to={'is_active': True, 'source_type': 'government'}, on_delete=django.db.models.deletion.PROTECT, related_name='government_tickets', to='px_sources.pxsource'), + ), + migrations.AddField( + model_name='governmentticket', + name='subsection', + field=models.ForeignKey(blank=True, help_text='Subsection within the section', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='government_tickets', to='organizations.subsection'), + ), + migrations.AddField( + model_name='inquiry', + name='assigned_to', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_inquiries', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='inquiry', + name='contacted_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiry_contacted', to=settings.AUTH_USER_MODEL, verbose_name='Contacted By'), + ), + migrations.AddField( + model_name='inquiry', + name='contacted_nr_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiry_contacted_nr', to=settings.AUTH_USER_MODEL, verbose_name='Contacted NR By'), + ), + migrations.AddField( + model_name='inquiry', + name='created_by', + field=models.ForeignKey(blank=True, help_text='User who created this inquiry (SourceUser or Patient)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_inquiries', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='inquiry', + name='deleted_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_%(class)s_set', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='inquiry', + name='department', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiries', to='organizations.department'), + ), + migrations.AddField( + model_name='inquiry', + name='department_responded_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='department_inquiry_responses', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='inquiry', + name='dept_response_accepted_by', + field=models.ForeignKey(blank=True, help_text='User who reviewed the department response', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_inquiry_dept_responses', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='inquiry', + name='follow_up_completed_by', + field=models.ForeignKey(blank=True, help_text='User who completed the follow-up call', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='completed_inquiry_followups', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='inquiry', + name='hospital', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inquiries', to='organizations.hospital'), + ), + migrations.AddField( + model_name='inquiry', + name='location', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiries', to='organizations.location'), + ), + migrations.AddField( + model_name='inquiry', + name='main_section', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiries', to='organizations.mainsection'), + ), + migrations.AddField( + model_name='inquiry', + name='outgoing_department', + field=models.ForeignKey(blank=True, help_text='Department that was contacted for this inquiry', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='outgoing_inquiries', to='organizations.department'), + ), + migrations.AddField( + model_name='inquiry', + name='patient', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inquiries', to='organizations.patient'), + ), + migrations.AddField( + model_name='inquiry', + name='responded_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='responded_inquiries', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='inquiry', + name='source', + field=models.ForeignKey(blank=True, help_text='Source of inquiry', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inquiries', to='px_sources.pxsource'), + ), + migrations.AddField( + model_name='inquiry', + name='subsection', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiries', to='organizations.subsection'), + ), + migrations.AddField( + model_name='inquiry', + name='taxonomy_category', + field=models.ForeignKey(blank=True, help_text='Level 2: Category', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiries_category', to='complaints.complaintcategory'), + ), + migrations.AddField( + model_name='inquiry', + name='taxonomy_classification', + field=models.ForeignKey(blank=True, help_text='Level 4: Classification', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiries_classification', to='complaints.complaintcategory'), + ), + migrations.AddField( + model_name='inquiry', + name='taxonomy_domain', + field=models.ForeignKey(blank=True, help_text='Level 1: Domain', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiries_domain', to='complaints.complaintcategory'), + ), + migrations.AddField( + model_name='inquiry', + name='taxonomy_subcategory', + field=models.ForeignKey(blank=True, help_text='Level 3: Subcategory', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiries_subcategory', to='complaints.complaintcategory'), + ), + migrations.AddField( + model_name='inquiry', + name='transferred_by', + field=models.ForeignKey(blank=True, help_text='User who transferred the inquiry', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transferred_inquiries', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='inquiry', + name='transferred_to_department', + field=models.ForeignKey(blank=True, help_text='Department the inquiry was transferred to', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transferred_inquiries', to='organizations.department'), + ), + migrations.AddField( + model_name='inquiry', + name='under_process_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiry_under_process', to=settings.AUTH_USER_MODEL, verbose_name='Under Process By'), + ), + migrations.AddField( + model_name='inquiryattachment', + name='inquiry', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='complaints.inquiry'), + ), + migrations.AddField( + model_name='inquiryattachment', + name='uploaded_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiry_attachments', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='inquiryexplanation', + name='inquiry', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='explanations', to='complaints.inquiry'), + ), + migrations.AddField( + model_name='inquiryexplanation', + name='requested_by', + field=models.ForeignKey(blank=True, help_text='User who requested the response', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requested_inquiry_explanations', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='inquiryexplanation', + name='staff', + field=models.ForeignKey(blank=True, help_text='Staff member who submitted the response', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiry_explanations', to='organizations.staff'), + ), + migrations.AddField( + model_name='inquiryexplanationattachment', + name='explanation', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='complaints.inquiryexplanation'), + ), + migrations.AddField( + model_name='inquiryslaconfig', + name='hospital', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inquiry_sla_configs', to='organizations.hospital'), + ), + migrations.AddField( + model_name='inquiryslaconfig', + name='source', + field=models.ForeignKey(blank=True, help_text='Inquiry source (MOH, CHI, Patient, etc.). Empty = default config', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiry_sla_configs', to='px_sources.pxsource'), + ), + migrations.AddField( + model_name='inquiryupdate', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiry_updates', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='inquiryupdate', + name='inquiry', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='updates', to='complaints.inquiry'), + ), + migrations.AddField( + model_name='oncalladmin', + name='admin_user', + field=models.ForeignKey(help_text='User who is on-call (PX Admin, PX Coordinator, or Hospital Admin)', on_delete=django.db.models.deletion.CASCADE, related_name='on_call_schedules', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='oncalladminschedule', + name='hospital', + field=models.ForeignKey(blank=True, help_text='Hospital scope. Leave empty for system-wide configuration.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='on_call_schedules', to='organizations.hospital'), + ), + migrations.AddField( + model_name='oncalladmin', + name='schedule', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='on_call_admins', to='complaints.oncalladminschedule'), + ), + migrations.AddField( + model_name='patientcomplaintsession', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaint_sessions', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='patientcomplaintsession', + name='patient', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complaint_sessions', to='organizations.patient'), + ), + migrations.AddIndex( + model_name='complaintadverseaction', + index=models.Index(fields=['complaint', '-incident_date'], name='complaints__complai_e2164d_idx'), + ), + migrations.AddIndex( + model_name='complaintadverseaction', + index=models.Index(fields=['action_type', 'severity'], name='complaints__action__502a3c_idx'), + ), + migrations.AddIndex( + model_name='complaintadverseaction', + index=models.Index(fields=['status', '-created_at'], name='complaints__status_d0a04c_idx'), + ), + migrations.AddIndex( + model_name='complaintcategory', + index=models.Index(fields=['code'], name='complaints__code_8e9bbe_idx'), + ), + migrations.AddIndex( + model_name='complaint', + index=models.Index(fields=['status', '-created_at'], name='complaints__status_f077e8_idx'), + ), + migrations.AddIndex( + model_name='complaint', + index=models.Index(fields=['hospital', 'status', '-created_at'], name='complaints__hospita_cf53df_idx'), + ), + migrations.AddIndex( + model_name='complaint', + index=models.Index(fields=['assigned_to', 'status', '-created_at'], name='complaints__assigne_68a48b_idx'), + ), + migrations.AddIndex( + model_name='complaint', + index=models.Index(fields=['department', 'status', '-created_at'], name='complaints__departm_fd6d45_idx'), + ), + migrations.AddIndex( + model_name='complaint', + index=models.Index(fields=['is_overdue', 'status'], name='complaints__is_over_3d3554_idx'), + ), + migrations.AddIndex( + model_name='complaint', + index=models.Index(fields=['due_at', 'status'], name='complaints__due_at_836821_idx'), + ), + migrations.AddIndex( + model_name='complaintcommunication', + index=models.Index(fields=['complaint', '-created_at'], name='complaints__complai_ae1be5_idx'), + ), + migrations.AddIndex( + model_name='complaintcommunication', + index=models.Index(fields=['communication_type'], name='complaints__communi_fffa08_idx'), + ), + migrations.AddIndex( + model_name='complaintcommunication', + index=models.Index(fields=['requires_followup', 'followup_date'], name='complaints__require_6cd08c_idx'), + ), + migrations.AddIndex( + model_name='complaintexplanation', + index=models.Index(fields=['complaint', '-created_at'], name='complaints__complai_b20e58_idx'), + ), + migrations.AddIndex( + model_name='complaintexplanation', + index=models.Index(fields=['token', 'is_used'], name='complaints__token_f8f9b7_idx'), + ), + migrations.AddIndex( + model_name='complaintinvolveddepartment', + index=models.Index(fields=['complaint', 'role'], name='complaints__complai_169f99_idx'), + ), + migrations.AddIndex( + model_name='complaintinvolveddepartment', + index=models.Index(fields=['department', 'response_submitted'], name='complaints__departm_07d2e5_idx'), + ), + migrations.AddIndex( + model_name='complaintinvolveddepartment', + index=models.Index(fields=['department', 'forwarded_at'], name='complaints__departm_4d974e_idx'), + ), + migrations.AlterUniqueTogether( + name='complaintinvolveddepartment', + unique_together={('complaint', 'department')}, + ), + migrations.AddIndex( + model_name='complaintinvolvedstaff', + index=models.Index(fields=['complaint', 'role'], name='complaints__complai_383082_idx'), + ), + migrations.AddIndex( + model_name='complaintinvolvedstaff', + index=models.Index(fields=['staff', 'explanation_received'], name='complaints__staff_i_222e56_idx'), + ), + migrations.AlterUniqueTogether( + name='complaintinvolvedstaff', + unique_together={('complaint', 'staff')}, + ), + migrations.AddIndex( + model_name='complaintmeeting', + index=models.Index(fields=['complaint', '-meeting_date'], name='complaints__complai_e0d679_idx'), + ), + migrations.AddIndex( + model_name='complaintprinteraction', + index=models.Index(fields=['complaint', '-contact_date'], name='complaints__complai_740a3c_idx'), + ), + migrations.AddIndex( + model_name='complaintslaconfig', + index=models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_bdf8a5_idx'), + ), + migrations.AddIndex( + model_name='complaintslaconfig', + index=models.Index(fields=['hospital', 'source', 'is_active'], name='complaints__hospita_072d9d_idx'), + ), + migrations.AlterUniqueTogether( + name='complaintslaconfig', + unique_together={('hospital', 'source', 'severity', 'priority')}, + ), + migrations.AddIndex( + model_name='complainttemplate', + index=models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_9e42d3_idx'), + ), + migrations.AddIndex( + model_name='complainttemplate', + index=models.Index(fields=['-usage_count'], name='complaints__usage_c_8dccb2_idx'), + ), + migrations.AlterUniqueTogether( + name='complainttemplate', + unique_together={('hospital', 'name')}, + ), + migrations.AddIndex( + model_name='complaintthreshold', + index=models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_b8efc9_idx'), + ), + migrations.AddIndex( + model_name='complaintthreshold', + index=models.Index(fields=['threshold_type', 'is_active'], name='complaints__thresho_719969_idx'), + ), + migrations.AddIndex( + model_name='complaintupdate', + index=models.Index(fields=['complaint', '-created_at'], name='complaints__complai_f3684e_idx'), + ), + migrations.AddIndex( + model_name='escalationrule', + index=models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_3c8bac_idx'), + ), + migrations.AddIndex( + model_name='explanationslaconfig', + index=models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_fe4ec5_idx'), + ), + migrations.AddIndex( + model_name='inquiry', + index=models.Index(fields=['status', '-created_at'], name='complaints__status_3d0678_idx'), + ), + migrations.AddIndex( + model_name='inquiry', + index=models.Index(fields=['hospital', 'status', '-created_at'], name='complaints__hospita_8643ee_idx'), + ), + migrations.AddIndex( + model_name='inquiry', + index=models.Index(fields=['assigned_to', 'status', '-created_at'], name='complaints__assigne_a54ba2_idx'), + ), + migrations.AddIndex( + model_name='inquiryexplanation', + index=models.Index(fields=['inquiry', '-created_at'], name='complaints__inquiry_5f2017_idx'), + ), + migrations.AddIndex( + model_name='inquiryexplanation', + index=models.Index(fields=['token', 'is_used'], name='complaints__token_61e029_idx'), + ), + migrations.AddIndex( + model_name='inquiryslaconfig', + index=models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_ef59ee_idx'), + ), + migrations.AddIndex( + model_name='inquiryslaconfig', + index=models.Index(fields=['hospital', 'source', 'is_active'], name='complaints__hospita_1b1d4c_idx'), + ), + migrations.AddIndex( + model_name='inquiryupdate', + index=models.Index(fields=['inquiry', '-created_at'], name='complaints__inquiry_551c37_idx'), + ), + migrations.AddConstraint( + model_name='oncalladminschedule', + constraint=models.UniqueConstraint(condition=models.Q(('hospital__isnull', False)), fields=('hospital',), name='unique_oncall_per_hospital'), + ), + migrations.AddConstraint( + model_name='oncalladminschedule', + constraint=models.UniqueConstraint(condition=models.Q(('hospital__isnull', True)), fields=('hospital',), name='unique_system_wide_oncall'), + ), + migrations.AlterUniqueTogether( + name='oncalladmin', + unique_together={('schedule', 'admin_user')}, + ), + migrations.AddIndex( + model_name='patientcomplaintsession', + index=models.Index(fields=['patient', 'is_active'], name='complaints__patient_07c7de_idx'), + ), + ] diff --git a/apps/core/migrations/0001_initial.py b/apps/core/migrations/0001_initial.py new file mode 100644 index 0000000..ce525c0 --- /dev/null +++ b/apps/core/migrations/0001_initial.py @@ -0,0 +1,39 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AuditEvent', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('event_type', models.CharField(choices=[('user_login', 'User Login'), ('user_logout', 'User Logout'), ('role_change', 'Role Change'), ('status_change', 'Status Change'), ('assignment', 'Assignment'), ('escalation', 'Escalation'), ('sla_breach', 'SLA Breach'), ('survey_sent', 'Survey Sent'), ('survey_completed', 'Survey Completed'), ('action_created', 'Action Created'), ('action_closed', 'Action Closed'), ('complaint_created', 'Complaint Created'), ('complaint_closed', 'Complaint Closed'), ('journey_started', 'Journey Started'), ('journey_completed', 'Journey Completed'), ('stage_completed', 'Stage Completed'), ('integration_event', 'Integration Event'), ('notification_sent', 'Notification Sent'), ('other', 'Other')], db_index=True, max_length=50)), + ('description', models.TextField()), + ('object_id', models.UUIDField(blank=True, null=True)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('user_agent', models.TextField(blank=True)), + ('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='audit_events', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['event_type', '-created_at'], name='core_audite_event_t_2e3170_idx'), models.Index(fields=['user', '-created_at'], name='core_audite_user_id_14c149_idx'), models.Index(fields=['content_type', 'object_id'], name='core_audite_content_7c950d_idx')], + }, + ), + ] diff --git a/apps/dashboard/migrations/0001_initial.py b/apps/dashboard/migrations/0001_initial.py new file mode 100644 index 0000000..79de551 --- /dev/null +++ b/apps/dashboard/migrations/0001_initial.py @@ -0,0 +1,143 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('complaints', '0001_initial'), + ('organizations', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ComplaintRequest', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('patient_name', models.CharField(blank=True, help_text='Patient name', max_length=200)), + ('file_number', models.CharField(blank=True, help_text='Patient file number', max_length=100)), + ('incident_date', models.DateField(blank=True, help_text='Date of the incident', null=True)), + ('phone_number', models.CharField(blank=True, help_text='Patient phone number', max_length=20)), + ('filled', models.BooleanField(default=False, help_text='Whether the request was filled')), + ('on_hold', models.BooleanField(default=False, help_text='Whether the request is on hold')), + ('not_filled', models.BooleanField(default=False, help_text='Whether the request was not filled')), + ('from_barcode', models.BooleanField(default=False, help_text='Whether the request came from barcode scanning')), + ('filling_time_category', models.CharField(choices=[('same_time', 'Same Time'), ('within_6h', 'Within 6 Hours'), ('6_to_24h', '6 to 24 Hours'), ('after_1_day', 'After 1 Day'), ('not_mentioned', 'Time Not Mentioned')], default='not_mentioned', help_text='When the request was filled', max_length=20)), + ('request_date', models.DateField(help_text='Date of the request')), + ('request_time', models.TimeField(blank=True, help_text='Time of the request', null=True)), + ('form_sent_at', models.DateTimeField(blank=True, help_text='When complaint form was sent to patient', null=True)), + ('form_sent_time', models.TimeField(blank=True, help_text='Time when form was sent', null=True)), + ('filled_at', models.DateTimeField(blank=True, help_text='When the request was filled', null=True)), + ('filled_time', models.TimeField(blank=True, help_text='Time when request was filled', null=True)), + ('reason_non_activation', models.CharField(blank=True, choices=[('converted_to_note', 'تم تحويلها ملاحظة (Converted to observation)'), ('issue_resolved_immediately', 'تم حل الاشكالية (Issue resolved immediately)'), ('not_meeting_conditions', 'غير مستوفية للشروط (Does not meet conditions)'), ('raised_via_cchi', 'تم رفع الشكوى عن طريق مجلس الضمان الصحي (Raised via CCHI)'), ('request_not_activated', 'لم يتم تفعيل طلب الشكوى (Request not activated)'), ('complainant_withdrew', 'بناء على طلب المشتكي (Per complainant request)'), ('complainant_retracted', 'المشتكي تنازل عن الشكوى (Complainant retracted)'), ('duplicate', 'مكررة (Duplicate)'), ('other', 'Other')], help_text='Reason complaint was not activated', max_length=50)), + ('reason_non_activation_other', models.CharField(blank=True, help_text='Other reason details', max_length=200)), + ('pr_observations', models.TextField(blank=True, help_text='PR team observations about this request')), + ('notes', models.TextField(blank=True, help_text='Additional notes about the request')), + ('complained_department', models.ForeignKey(blank=True, help_text='Department being complained about', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaint_requests', to='organizations.department')), + ('complaint', models.ForeignKey(blank=True, help_text='Related complaint (if any)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaint_request_records', to='complaints.complaint')), + ('hospital', models.ForeignKey(blank=True, help_text='Hospital where the request was made', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaint_requests', to='organizations.hospital')), + ('staff', models.ForeignKey(help_text='Staff member who sent/filled the request', on_delete=django.db.models.deletion.CASCADE, related_name='complaint_requests_sent', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Complaint Request', + 'verbose_name_plural': 'Complaint Requests', + 'ordering': ['-request_date', '-created_at'], + 'indexes': [models.Index(fields=['staff', 'request_date'], name='dashboard_c_staff_i_6d32eb_idx'), models.Index(fields=['filled', 'on_hold'], name='dashboard_c_filled_9bb46e_idx'), models.Index(fields=['request_date'], name='dashboard_c_request_f114d3_idx'), models.Index(fields=['hospital', 'request_date'], name='dashboard_c_hospita_def391_idx'), models.Index(fields=['reason_non_activation'], name='dashboard_c_reason__fe9deb_idx')], + }, + ), + migrations.CreateModel( + name='EscalatedComplaintLog', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('escalation_timing', models.CharField(choices=[('before_72h', 'Before 72 Hours'), ('exactly_72h', '72 Hours Exactly'), ('after_72h', 'After 72 Hours')], help_text='When the complaint was escalated relative to 72h SLA', max_length=20)), + ('escalated_at', models.DateTimeField(help_text='When the complaint was escalated')), + ('is_resolved', models.BooleanField(default=False, help_text='Whether the escalated complaint was resolved')), + ('resolved_at', models.DateTimeField(blank=True, help_text='When the escalated complaint was resolved', null=True)), + ('resolution_notes', models.TextField(blank=True, help_text='Notes about the resolution')), + ('week_start_date', models.DateField(help_text='Start date of the week this escalation is recorded for')), + ('complaint', models.ForeignKey(help_text='The escalated complaint', on_delete=django.db.models.deletion.CASCADE, related_name='escalation_logs', to='complaints.complaint')), + ('staff', models.ForeignKey(help_text='Staff member who the complaint is assigned to', on_delete=django.db.models.deletion.CASCADE, related_name='escalated_complaints', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Escalated Complaint Log', + 'verbose_name_plural': 'Escalated Complaint Logs', + 'ordering': ['-escalated_at', '-created_at'], + 'indexes': [models.Index(fields=['staff', 'week_start_date'], name='dashboard_e_staff_i_6fc418_idx'), models.Index(fields=['escalation_timing', 'is_resolved'], name='dashboard_e_escalat_06e4bf_idx'), models.Index(fields=['week_start_date'], name='dashboard_e_week_st_bf1d71_idx')], + }, + ), + migrations.CreateModel( + name='EvaluationNote', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('category', models.CharField(choices=[('non_medical', 'Non-Medical'), ('medical', 'Medical'), ('er', 'ER'), ('hospital', 'Hospital')], help_text='Note category', max_length=50)), + ('sub_category', models.CharField(choices=[('it_app', 'IT - App'), ('lab', 'LAB'), ('doctors_managers_reception', 'Doctors/Managers/Reception'), ('hospital_general', 'Hospital'), ('medical_reports', 'Medical Reports'), ('doctors', 'Doctors'), ('other', 'Other')], help_text='Note sub-category', max_length=50)), + ('count', models.IntegerField(default=1, help_text='Number of notes in this category')), + ('note_date', models.DateField(help_text='Date the note was recorded')), + ('description', models.TextField(blank=True, help_text='Optional description')), + ('created_by', models.ForeignKey(blank=True, help_text='User who created this note entry', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='notes_created', to=settings.AUTH_USER_MODEL)), + ('staff', models.ForeignKey(help_text='Staff member this note is for', on_delete=django.db.models.deletion.CASCADE, related_name='evaluation_notes', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Evaluation Note', + 'verbose_name_plural': 'Evaluation Notes', + 'ordering': ['-note_date', 'category', 'sub_category'], + 'indexes': [models.Index(fields=['staff', 'note_date'], name='dashboard_e_staff_i_5cb15c_idx'), models.Index(fields=['category', 'sub_category'], name='dashboard_e_categor_1b7060_idx'), models.Index(fields=['note_date'], name='dashboard_e_note_da_51e000_idx')], + }, + ), + migrations.CreateModel( + name='InquiryDetail', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('inquiry_type', models.CharField(choices=[('contact_doctor', 'Contact the doctor'), ('sick_leave_reports', 'Sick-Leave - Medical Reports'), ('blood_test', 'Blood test result'), ('raise_complaint', 'Raise a Complaint'), ('app_problem', 'Problem with the app'), ('medication', 'Ask about medication'), ('insurance_status', 'Insurance request status'), ('general_question', 'General question'), ('other', 'Other')], help_text='Type of inquiry', max_length=50)), + ('is_outgoing', models.BooleanField(default=False, help_text='Whether this is an outgoing inquiry')), + ('response_time_category', models.CharField(blank=True, choices=[('24h', '24 Hours'), ('48h', '48 Hours'), ('72h', '72 Hours'), ('more_than_72h', 'More than 72 Hours')], help_text='Response time category', max_length=20)), + ('inquiry_status', models.CharField(blank=True, choices=[('in_progress', 'تحت الإجراء (In Progress)'), ('contacted', 'تم التواصل (Contacted)'), ('contacted_no_response', 'تم التواصل ولم يتم الرد (Contacted No Response)')], help_text='Current status of the inquiry', max_length=50)), + ('inquiry_date', models.DateField(help_text='Date of the inquiry')), + ('notes', models.TextField(blank=True, help_text='Additional notes')), + ('inquiry', models.OneToOneField(help_text='Related inquiry', on_delete=django.db.models.deletion.CASCADE, related_name='evaluation_detail', to='complaints.inquiry')), + ('staff', models.ForeignKey(help_text='Staff member handling the inquiry', on_delete=django.db.models.deletion.CASCADE, related_name='inquiry_details', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Inquiry Detail', + 'verbose_name_plural': 'Inquiry Details', + 'ordering': ['-inquiry_date', '-created_at'], + 'indexes': [models.Index(fields=['staff', 'inquiry_date'], name='dashboard_i_staff_i_cee374_idx'), models.Index(fields=['inquiry_type', 'is_outgoing'], name='dashboard_i_inquiry_5064f6_idx'), models.Index(fields=['inquiry_date'], name='dashboard_i_inquiry_4e47dd_idx')], + }, + ), + migrations.CreateModel( + name='ReportCompletion', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('report_type', models.CharField(choices=[('complaint_report', 'Complaint Report'), ('complaint_request_report', 'Complaint Request Report'), ('observation_report', 'Observation Report'), ('incoming_inquiries_report', 'Incoming Inquiries Report'), ('outgoing_inquiries_report', 'Outgoing Inquiries Report'), ('extension_report', 'Extension Report'), ('escalated_complaints_report', 'Escalated Complaints Report')], help_text='Type of report', max_length=50)), + ('is_completed', models.BooleanField(default=False, help_text='Whether the report is completed')), + ('completed_at', models.DateTimeField(blank=True, help_text='When the report was completed', null=True)), + ('week_start_date', models.DateField(help_text='Start date of the week this report is for')), + ('notes', models.TextField(blank=True, help_text='Notes about the report completion')), + ('staff', models.ForeignKey(help_text='Staff member who should complete the report', on_delete=django.db.models.deletion.CASCADE, related_name='report_completions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Report Completion', + 'verbose_name_plural': 'Report Completions', + 'ordering': ['-week_start_date', 'report_type'], + 'indexes': [models.Index(fields=['staff', 'week_start_date'], name='dashboard_r_staff_i_e94140_idx'), models.Index(fields=['report_type', 'is_completed'], name='dashboard_r_report__6e699b_idx'), models.Index(fields=['week_start_date'], name='dashboard_r_week_st_8558ae_idx')], + 'unique_together': {('staff', 'report_type', 'week_start_date')}, + }, + ), + ] diff --git a/apps/executive_summary/migrations/0001_initial.py b/apps/executive_summary/migrations/0001_initial.py new file mode 100644 index 0000000..f101bb0 --- /dev/null +++ b/apps/executive_summary/migrations/0001_initial.py @@ -0,0 +1,150 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('organizations', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ExecutiveReport', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('report_type', models.CharField(choices=[('weekly', 'Weekly Summary'), ('monthly', 'Monthly Summary'), ('quarterly', 'Quarterly Summary'), ('custom', 'Custom Report')], db_index=True, max_length=20)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('generating', 'Generating'), ('completed', 'Completed'), ('failed', 'Failed')], db_index=True, default='pending', max_length=20)), + ('start_date', models.DateField()), + ('end_date', models.DateField()), + ('narrative_en', models.TextField(blank=True, help_text='English narrative summary')), + ('narrative_ar', models.TextField(blank=True, help_text='Arabic narrative summary')), + ('highlights_en', models.JSONField(blank=True, default=list, help_text='Key highlights (English)')), + ('highlights_ar', models.JSONField(blank=True, default=list, help_text='Key highlights (Arabic)')), + ('concerns_en', models.JSONField(blank=True, default=list, help_text='Key concerns (English)')), + ('concerns_ar', models.JSONField(blank=True, default=list, help_text='Key concerns (Arabic)')), + ('metrics_snapshot', models.JSONField(blank=True, default=dict, help_text='Key metrics at report time')), + ('ai_model', models.CharField(blank=True, help_text='AI model used for generation', max_length=100)), + ('generation_time_ms', models.IntegerField(blank=True, help_text='Generation time in milliseconds', null=True)), + ('pdf_file', models.FileField(blank=True, help_text='Generated PDF report', null=True, upload_to='executive_reports/pdfs/%Y/%m/')), + ('error_message', models.TextField(blank=True, help_text='Error message if generation failed')), + ], + options={ + 'verbose_name': 'Executive Report', + 'verbose_name_plural': 'Executive Reports', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['report_type', '-created_at'], name='executive_s_report__fd1ff4_idx'), models.Index(fields=['status', '-created_at'], name='executive_s_status_57ab9f_idx')], + }, + ), + migrations.CreateModel( + name='PredictiveInsight', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('insight_type', models.CharField(choices=[('trend_change', 'Trend Change'), ('anomaly', 'Anomaly Detected'), ('risk_warning', 'Risk Warning'), ('sla_breach_risk', 'SLA Breach Risk'), ('performance_drop', 'Performance Drop'), ('volume_spike', 'Volume Spike'), ('satisfaction_decline', 'Satisfaction Decline'), ('positive_trend', 'Positive Trend')], db_index=True, max_length=30)), + ('severity', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], db_index=True, default='medium', max_length=10)), + ('status', models.CharField(choices=[('new', 'New'), ('acknowledged', 'Acknowledged'), ('resolved', 'Resolved'), ('dismissed', 'Dismissed')], db_index=True, default='new', max_length=20)), + ('title_en', models.CharField(max_length=300)), + ('title_ar', models.CharField(blank=True, max_length=300)), + ('description_en', models.TextField()), + ('description_ar', models.TextField(blank=True)), + ('recommendation_en', models.TextField(blank=True)), + ('recommendation_ar', models.TextField(blank=True)), + ('metric_type', models.CharField(blank=True, help_text='Related metric type', max_length=50)), + ('entity_type', models.CharField(blank=True, help_text='Type of related entity (e.g., complaint, px_action)', max_length=50)), + ('entity_id', models.CharField(blank=True, help_text='ID of related entity', max_length=100)), + ('current_value', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), + ('predicted_value', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), + ('confidence_score', models.DecimalField(blank=True, decimal_places=2, help_text='AI confidence score (0-100)', max_digits=5, null=True)), + ('predicted_date', models.DateField(blank=True, help_text='When this is expected to occur', null=True)), + ('acknowledged_at', models.DateTimeField(blank=True, null=True)), + ('ai_model', models.CharField(blank=True, max_length=100)), + ('detection_metadata', models.JSONField(blank=True, default=dict)), + ('acknowledged_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='acknowledged_insights', to=settings.AUTH_USER_MODEL)), + ('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='predictive_insights', to='organizations.department')), + ('hospital', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='predictive_insights', to='organizations.hospital')), + ], + options={ + 'verbose_name': 'Predictive Insight', + 'verbose_name_plural': 'Predictive Insights', + 'ordering': ['-severity', '-created_at'], + }, + ), + migrations.CreateModel( + name='AIRecommendation', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('category', models.CharField(choices=[('process_improvement', 'Process Improvement'), ('resource_allocation', 'Resource Allocation'), ('training', 'Training'), ('policy_change', 'Policy Change'), ('preventive_action', 'Preventive Action'), ('performance_optimization', 'Performance Optimization'), ('communication', 'Communication'), ('quality_assurance', 'Quality Assurance')], db_index=True, max_length=30)), + ('priority', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('urgent', 'Urgent')], db_index=True, default='medium', max_length=10)), + ('status', models.CharField(choices=[('new', 'New'), ('under_review', 'Under Review'), ('approved', 'Approved'), ('implemented', 'Implemented'), ('rejected', 'Rejected')], db_index=True, default='new', max_length=20)), + ('title_en', models.CharField(max_length=300)), + ('title_ar', models.CharField(blank=True, max_length=300)), + ('description_en', models.TextField()), + ('description_ar', models.TextField(blank=True)), + ('expected_impact_en', models.TextField(blank=True, help_text='Expected impact if implemented (English)')), + ('expected_impact_ar', models.TextField(blank=True, help_text='Expected impact if implemented (Arabic)')), + ('implemented_at', models.DateTimeField(blank=True, null=True)), + ('confidence_score', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True)), + ('ai_model', models.CharField(blank=True, max_length=100)), + ('generation_metadata', models.JSONField(blank=True, default=dict)), + ('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ai_recommend', to='organizations.department')), + ('hospital', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ai_recommend', to='organizations.hospital')), + ('implemented_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='implemented_recommendations', to=settings.AUTH_USER_MODEL)), + ('related_insight', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='recommendations', to='executive_summary.predictiveinsight')), + ], + options={ + 'verbose_name': 'AI Recommendation', + 'verbose_name_plural': 'AI Recommendations', + 'ordering': ['-priority', '-created_at'], + }, + ), + migrations.CreateModel( + name='ExecutiveMetric', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('metric_date', models.DateField(db_index=True, help_text='Date of this metric snapshot')), + ('metric_type', models.CharField(choices=[('complaints_total', 'Total Complaints'), ('complaints_critical', 'Critical Complaints'), ('complaints_overdue', 'Overdue Complaints'), ('complaints_resolution_time', 'Avg Resolution Time (hours)'), ('surveys_total', 'Total Surveys'), ('surveys_satisfaction', 'Satisfaction Rate %'), ('surveys_nps', 'NPS Score'), ('surveys_response_rate', 'Response Rate %'), ('actions_total', 'Total Actions'), ('actions_open', 'Open Actions'), ('actions_overdue', 'Overdue Actions'), ('actions_closed', 'Closed Actions'), ('observations_total', 'Total Observations'), ('observations_critical', 'Critical Observations'), ('inquiries_total', 'Total Inquiries'), ('inquiries_resolved', 'Resolved Inquiries'), ('call_center_total', 'Call Center Interactions'), ('call_center_satisfaction', 'Call Center Satisfaction %'), ('physician_avg_rating', 'Avg Physician Rating')], db_index=True, max_length=50)), + ('metric_value', models.DecimalField(decimal_places=2, max_digits=10)), + ('variance', models.DecimalField(blank=True, decimal_places=2, help_text='Variance from previous period', max_digits=10, null=True)), + ('variance_direction', models.CharField(choices=[('up', 'Up'), ('down', 'Down'), ('neutral', 'Neutral')], default='neutral', help_text='Direction of variance', max_length=10)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('hospital', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='executive_metrics', to='organizations.hospital')), + ], + options={ + 'verbose_name': 'Executive Metric', + 'verbose_name_plural': 'Executive Metrics', + 'ordering': ['-metric_date', 'metric_type'], + 'indexes': [models.Index(fields=['metric_date', 'metric_type'], name='executive_s_metric__f55857_idx'), models.Index(fields=['hospital', 'metric_date'], name='executive_s_hospita_9e8443_idx')], + }, + ), + migrations.AddIndex( + model_name='predictiveinsight', + index=models.Index(fields=['status', 'severity', '-created_at'], name='executive_s_status_35974a_idx'), + ), + migrations.AddIndex( + model_name='predictiveinsight', + index=models.Index(fields=['hospital', 'status'], name='executive_s_hospita_18595e_idx'), + ), + migrations.AddIndex( + model_name='airecommendation', + index=models.Index(fields=['status', 'priority', '-created_at'], name='executive_s_status_f2a1e6_idx'), + ), + migrations.AddIndex( + model_name='airecommendation', + index=models.Index(fields=['hospital', 'status'], name='executive_s_hospita_322e48_idx'), + ), + ] diff --git a/apps/feedback/migrations/0001_initial.py b/apps/feedback/migrations/0001_initial.py new file mode 100644 index 0000000..0324a65 --- /dev/null +++ b/apps/feedback/migrations/0001_initial.py @@ -0,0 +1,180 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('organizations', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='FeedbackAttachment', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('file', models.FileField(upload_to='feedback/%Y/%m/%d/')), + ('filename', models.CharField(max_length=500)), + ('file_type', models.CharField(blank=True, max_length=100)), + ('file_size', models.IntegerField(help_text='File size in bytes')), + ('description', models.TextField(blank=True)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='FeedbackResponse', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('response_type', models.CharField(choices=[('status_change', 'Status Change'), ('assignment', 'Assignment'), ('note', 'Internal Note'), ('response', 'Response to Patient'), ('acknowledgment', 'Acknowledgment')], db_index=True, max_length=50)), + ('message', models.TextField()), + ('old_status', models.CharField(blank=True, max_length=20)), + ('new_status', models.CharField(blank=True, max_length=20)), + ('is_internal', models.BooleanField(default=False, help_text='Internal note (not visible to patient)')), + ('metadata', models.JSONField(blank=True, default=dict)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='PatientComment', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('serial_number', models.IntegerField(blank=True, help_text='Serial number from the source file', null=True)), + ('source_category', models.CharField(blank=True, choices=[('appointment', 'Appointment'), ('inpatient', 'Inpatient'), ('outpatient', 'Outpatient')], db_index=True, help_text='Source category from IT export (Appointment/Inpatient/Outpatient)', max_length=20)), + ('comment_text', models.TextField(help_text='Original comment text (Arabic/English)')), + ('comment_text_en', models.TextField(blank=True, help_text='English translation of the comment')), + ('classification', models.CharField(blank=True, choices=[('hospital', 'Hospital'), ('medical', 'Medical'), ('non_medical', 'Non-Medical'), ('nursing', 'Nursing'), ('er', 'Emergency'), ('support_services', 'Support Services')], db_index=True, help_text='Primary classification (Hospital/Medical/Non-Medical/Nursing/ER/Support)', max_length=20)), + ('sub_category', models.CharField(blank=True, choices=[('pharmacy', 'Pharmacy'), ('rad', 'RAD'), ('lab', 'LAB'), ('physiotherapy', 'Physiotherapy'), ('doctors', 'Doctors'), ('medical_reports', 'Medical Reports'), ('reception', 'Reception'), ('insurance_approvals', 'Insurance/Approvals'), ('opd_clinics', 'OPD - Clinics'), ('appointments', 'Appointments'), ('it_app', 'IT - App'), ('administration', 'Administration'), ('billing', 'Billing'), ('facilities', 'Facilities'), ('food_services', 'Food Services'), ('parking', 'Parking'), ('housekeeping', 'Housekeeping'), ('other', 'Other')], db_index=True, help_text='Sub-category classification (e.g., Pharmacy, Reception, etc.)', max_length=30)), + ('negative_keywords', models.TextField(blank=True, help_text='Negative sentiment keywords/phrases extracted')), + ('positive_keywords', models.TextField(blank=True, help_text='Positive sentiment keywords/phrases extracted')), + ('gratitude_keywords', models.TextField(blank=True, help_text='Gratitude keywords/phrases extracted')), + ('suggestions', models.TextField(blank=True, help_text='Suggestion text extracted from the comment')), + ('sentiment', models.CharField(blank=True, choices=[('positive', 'Positive'), ('neutral', 'Neutral'), ('negative', 'Negative')], db_index=True, help_text='Overall sentiment classification', max_length=20)), + ('is_classified', models.BooleanField(db_index=True, default=False, help_text='Whether this comment has been classified')), + ('mentioned_doctor_name', models.CharField(blank=True, help_text='Name of doctor mentioned in the comment', max_length=200)), + ('mentioned_doctor_name_en', models.CharField(blank=True, help_text='English name of mentioned doctor', max_length=200)), + ('frequency', models.IntegerField(default=1, help_text='How many times this comment/problem has been reported')), + ('month', models.IntegerField(blank=True, help_text='Month the comment was collected', null=True)), + ('year', models.IntegerField(blank=True, help_text='Year the comment was collected', null=True)), + ('metadata', models.JSONField(blank=True, default=dict)), + ], + options={ + 'verbose_name': 'Patient Comment', + 'verbose_name_plural': 'Patient Comments', + 'ordering': ['-year', '-month', '-serial_number'], + }, + ), + migrations.CreateModel( + name='CommentActionPlan', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('department_label', models.CharField(blank=True, help_text='Free-text department name (for when no FK exists)', max_length=200)), + ('problem_number', models.IntegerField(blank=True, help_text='Problem number within the department', null=True)), + ('comment_text', models.TextField(blank=True, help_text='Related comment text')), + ('comment_text_en', models.TextField(blank=True, help_text='English translation of the comment')), + ('frequency', models.IntegerField(default=1, help_text='Number of times this problem was reported')), + ('recommendation', models.TextField(help_text='Recommendation / Action Plan')), + ('recommendation_en', models.TextField(blank=True, help_text='English translation of the recommendation')), + ('responsible_department', models.CharField(blank=True, help_text='Free-text responsible department name', max_length=200)), + ('status', models.CharField(choices=[('completed', 'Completed'), ('on_process', 'On Process'), ('pending', 'Pending')], db_index=True, default='pending', max_length=20)), + ('timeframe', models.CharField(blank=True, help_text='Target timeframe (e.g., Q3, 3 months, 2025-Q1)', max_length=100)), + ('evidences', models.TextField(blank=True, help_text='Evidence of completion / notes')), + ('month', models.IntegerField(blank=True, null=True)), + ('year', models.IntegerField(blank=True, null=True)), + ('department', models.ForeignKey(blank=True, help_text='Responsible department', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='comment_action_plans', to='organizations.department')), + ('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_action_plans', to='organizations.hospital')), + ], + options={ + 'verbose_name': 'Comment Action Plan', + 'verbose_name_plural': 'Comment Action Plans', + 'ordering': ['department_label', 'problem_number'], + }, + ), + migrations.CreateModel( + name='CommentImport', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('month', models.IntegerField(help_text='Month number (1-12)')), + ('year', models.IntegerField(help_text='Year')), + ('source_file', models.FileField(blank=True, help_text='Uploaded source file from IT department', upload_to='comments/imports/%Y/%m/')), + ('status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed')], default='pending', max_length=20)), + ('total_rows', models.IntegerField(default=0, help_text='Total rows in the import file')), + ('imported_count', models.IntegerField(default=0, help_text='Number of comments successfully imported')), + ('error_count', models.IntegerField(default=0)), + ('error_log', models.TextField(blank=True)), + ('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_imports', to='organizations.hospital')), + ('imported_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='comment_imports', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Comment Import', + 'verbose_name_plural': 'Comment Imports', + 'ordering': ['-year', '-month'], + }, + ), + migrations.CreateModel( + name='Feedback', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('is_deleted', models.BooleanField(db_index=True, default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('is_anonymous', models.BooleanField(default=False)), + ('contact_name', models.CharField(blank=True, max_length=200)), + ('contact_email', models.EmailField(blank=True, max_length=254)), + ('contact_phone', models.CharField(blank=True, max_length=20)), + ('encounter_id', models.CharField(blank=True, db_index=True, help_text='Related encounter ID if applicable', max_length=100)), + ('feedback_type', models.CharField(choices=[('compliment', 'Compliment'), ('suggestion', 'Suggestion'), ('general', 'General Feedback'), ('inquiry', 'Inquiry'), ('satisfaction_check', 'Satisfaction Check')], db_index=True, default='general', max_length=20)), + ('title', models.CharField(max_length=500)), + ('message', models.TextField(help_text='Feedback message')), + ('category', models.CharField(choices=[('clinical_care', 'Clinical Care'), ('staff_service', 'Staff Service'), ('facility', 'Facility & Environment'), ('communication', 'Communication'), ('appointment', 'Appointment & Scheduling'), ('billing', 'Billing & Insurance'), ('food_service', 'Food Service'), ('cleanliness', 'Cleanliness'), ('technology', 'Technology & Systems'), ('other', 'Other')], db_index=True, max_length=50)), + ('subcategory', models.CharField(blank=True, max_length=100)), + ('rating', models.IntegerField(blank=True, help_text='Rating from 1 to 5 stars', null=True)), + ('priority', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], db_index=True, default='medium', max_length=20)), + ('sentiment', models.CharField(choices=[('positive', 'Positive'), ('neutral', 'Neutral'), ('negative', 'Negative')], db_index=True, default='neutral', help_text='Sentiment analysis result', max_length=20)), + ('sentiment_score', models.FloatField(blank=True, help_text='Sentiment score from -1 (negative) to 1 (positive)', null=True)), + ('status', models.CharField(choices=[('submitted', 'Submitted'), ('reviewed', 'Reviewed'), ('acknowledged', 'Acknowledged'), ('closed', 'Closed')], db_index=True, default='submitted', max_length=20)), + ('assigned_at', models.DateTimeField(blank=True, null=True)), + ('reviewed_at', models.DateTimeField(blank=True, null=True)), + ('acknowledged_at', models.DateTimeField(blank=True, null=True)), + ('closed_at', models.DateTimeField(blank=True, null=True)), + ('is_featured', models.BooleanField(default=False, help_text='Feature this feedback (e.g., for testimonials)')), + ('is_public', models.BooleanField(default=False, help_text='Make this feedback public')), + ('requires_follow_up', models.BooleanField(default=False)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('acknowledged_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='acknowledged_feedbacks', to=settings.AUTH_USER_MODEL)), + ('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_feedbacks', to=settings.AUTH_USER_MODEL)), + ('closed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='closed_feedbacks', to=settings.AUTH_USER_MODEL)), + ('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_%(class)s_set', to=settings.AUTH_USER_MODEL)), + ('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.department')), + ('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feedbacks', to='organizations.hospital')), + ('location', models.ForeignKey(blank=True, help_text='Location context', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.location')), + ('main_section', models.ForeignKey(blank=True, help_text='Main section within the location', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.mainsection')), + ('patient', models.ForeignKey(blank=True, help_text='Patient who provided feedback (optional for anonymous feedback)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='feedbacks', to='organizations.patient')), + ], + options={ + 'verbose_name_plural': 'Feedback', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/apps/feedback/migrations/0002_initial.py b/apps/feedback/migrations/0002_initial.py new file mode 100644 index 0000000..95dd784 --- /dev/null +++ b/apps/feedback/migrations/0002_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('feedback', '0001_initial'), + ('surveys', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='feedback', + name='related_survey', + field=models.ForeignKey(blank=True, help_text='Survey that triggered this satisfaction check feedback', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='follow_up_feedbacks', to='surveys.surveyinstance'), + ), + migrations.AddField( + model_name='feedback', + name='reviewed_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_feedbacks', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/apps/feedback/migrations/0003_initial.py b/apps/feedback/migrations/0003_initial.py new file mode 100644 index 0000000..4b1a00d --- /dev/null +++ b/apps/feedback/migrations/0003_initial.py @@ -0,0 +1,122 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('feedback', '0002_initial'), + ('organizations', '0001_initial'), + ('px_sources', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='feedback', + name='source', + field=models.ForeignKey(blank=True, help_text='Source of feedback', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='feedbacks', to='px_sources.pxsource'), + ), + migrations.AddField( + model_name='feedback', + name='staff', + field=models.ForeignKey(blank=True, help_text='Staff member being mentioned in feedback', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.staff'), + ), + migrations.AddField( + model_name='feedback', + name='subsection', + field=models.ForeignKey(blank=True, help_text='Specific subsection', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.subsection'), + ), + migrations.AddField( + model_name='feedbackattachment', + name='feedback', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='feedback.feedback'), + ), + migrations.AddField( + model_name='feedbackattachment', + name='uploaded_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedback_attachments', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='feedbackresponse', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedback_responses', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='feedbackresponse', + name='feedback', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='feedback.feedback'), + ), + migrations.AddField( + model_name='patientcomment', + name='comment_import', + field=models.ForeignKey(blank=True, help_text='Import batch this comment came from', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='feedback.commentimport'), + ), + migrations.AddField( + model_name='patientcomment', + name='hospital', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='patient_comments', to='organizations.hospital'), + ), + migrations.AddField( + model_name='commentactionplan', + name='comment', + field=models.ForeignKey(blank=True, help_text='Source comment (may be null if aggregated from multiple)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='action_plans', to='feedback.patientcomment'), + ), + migrations.AlterUniqueTogether( + name='commentimport', + unique_together={('hospital', 'year', 'month')}, + ), + migrations.AddIndex( + model_name='feedback', + index=models.Index(fields=['status', '-created_at'], name='feedback_fe_status_212662_idx'), + ), + migrations.AddIndex( + model_name='feedback', + index=models.Index(fields=['hospital', 'status', '-created_at'], name='feedback_fe_hospita_4c1146_idx'), + ), + migrations.AddIndex( + model_name='feedback', + index=models.Index(fields=['department', 'status', '-created_at'], name='feedback_fe_departm_be6aa4_idx'), + ), + migrations.AddIndex( + model_name='feedback', + index=models.Index(fields=['assigned_to', 'status', '-created_at'], name='feedback_fe_assigne_c79fd3_idx'), + ), + migrations.AddIndex( + model_name='feedback', + index=models.Index(fields=['feedback_type', '-created_at'], name='feedback_fe_feedbac_6b63a4_idx'), + ), + migrations.AddIndex( + model_name='feedback', + index=models.Index(fields=['is_deleted', '-created_at'], name='feedback_fe_is_dele_f543d5_idx'), + ), + migrations.AddIndex( + model_name='feedbackresponse', + index=models.Index(fields=['feedback', '-created_at'], name='feedback_fe_feedbac_bc9e33_idx'), + ), + migrations.AddIndex( + model_name='patientcomment', + index=models.Index(fields=['hospital', 'classification', 'sub_category'], name='feedback_pa_hospita_008b6d_idx'), + ), + migrations.AddIndex( + model_name='patientcomment', + index=models.Index(fields=['hospital', 'year', 'month'], name='feedback_pa_hospita_2b8ae8_idx'), + ), + migrations.AddIndex( + model_name='patientcomment', + index=models.Index(fields=['hospital', 'source_category'], name='feedback_pa_hospita_af40c7_idx'), + ), + migrations.AddIndex( + model_name='commentactionplan', + index=models.Index(fields=['hospital', 'status'], name='feedback_co_hospita_8139f7_idx'), + ), + migrations.AddIndex( + model_name='commentactionplan', + index=models.Index(fields=['hospital', 'year', 'month'], name='feedback_co_hospita_8565e5_idx'), + ), + ] diff --git a/apps/integrations/migrations/0001_initial.py b/apps/integrations/migrations/0001_initial.py new file mode 100644 index 0000000..f78e97d --- /dev/null +++ b/apps/integrations/migrations/0001_initial.py @@ -0,0 +1,204 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('organizations', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='EventMapping', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('external_event_code', models.CharField(help_text='Event code from external system', max_length=100)), + ('internal_event_code', models.CharField(help_text='Internal event code used in journey stages', max_length=100)), + ('field_mappings', models.JSONField(blank=True, default=dict, help_text='Maps external field names to internal field names')), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'ordering': ['integration_config', 'external_event_code'], + }, + ), + migrations.CreateModel( + name='HISEventType', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('event_type', models.CharField(help_text="HIS event type name (e.g., 'Lab Bill', 'Triage')", max_length=200, unique=True)), + ('patient_types', models.JSONField(blank=True, default=list, help_text="Patient types that have this event: ['OP', 'IP', 'ED']")), + ('event_count', models.IntegerField(default=0, help_text='Total number of times this event type has been seen')), + ('last_seen_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'HIS Event Type', + 'verbose_name_plural': 'HIS Event Types', + 'ordering': ['event_type'], + }, + ), + migrations.CreateModel( + name='HISTestPatient', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('admission_id', models.CharField(db_index=True, max_length=100)), + ('patient_id', models.CharField(db_index=True, max_length=100)), + ('patient_type', models.CharField(db_index=True, max_length=10)), + ('reg_code', models.CharField(blank=True, db_index=True, max_length=100)), + ('ssn', models.CharField(blank=True, db_index=True, max_length=50)), + ('mobile_no', models.CharField(blank=True, db_index=True, max_length=20)), + ('admit_date', models.DateTimeField(db_index=True)), + ('discharge_date', models.DateTimeField(blank=True, null=True)), + ('patient_data', models.JSONField(default=dict)), + ('hospital_id', models.CharField(blank=True, max_length=20)), + ('hospital_name', models.CharField(blank=True, max_length=200)), + ('patient_name', models.CharField(blank=True, max_length=300)), + ], + options={ + 'ordering': ['admit_date'], + }, + ), + migrations.CreateModel( + name='HISTestVisit', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('admission_id', models.CharField(db_index=True, max_length=100)), + ('patient_id', models.CharField(db_index=True, max_length=100)), + ('visit_category', models.CharField(db_index=True, max_length=10)), + ('event_type', models.CharField(blank=True, max_length=200)), + ('bill_date', models.DateTimeField(blank=True, db_index=True, null=True)), + ('reg_code', models.CharField(blank=True, max_length=100)), + ('ssn', models.CharField(blank=True, db_index=True, max_length=50)), + ('mobile_no', models.CharField(blank=True, db_index=True, max_length=20)), + ('visit_data', models.JSONField(default=dict)), + ], + options={ + 'ordering': ['bill_date'], + }, + ), + migrations.CreateModel( + name='HISVisitEvent', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('event_type', models.CharField(blank=True, help_text="Type from HIS (e.g., 'Registration')", max_length=200)), + ('bill_date', models.CharField(blank=True, help_text='Raw BillDate from HIS (DD-Mon-YYYY HH:MM)', max_length=50)), + ('parsed_date', models.DateTimeField(blank=True, db_index=True, help_text='Parsed bill_date', null=True)), + ('patient_type', models.CharField(blank=True, help_text='PatientType for this event', max_length=10)), + ('visit_category', models.CharField(blank=True, help_text='Visit category: ED, IP, OP', max_length=10)), + ('admission_id', models.CharField(blank=True, db_index=True, max_length=100)), + ('patient_id', models.CharField(blank=True, db_index=True, help_text='PatientID from HIS', max_length=100)), + ('reg_code', models.CharField(blank=True, db_index=True, max_length=100)), + ('ssn', models.CharField(blank=True, db_index=True, max_length=50)), + ('mobile_no', models.CharField(blank=True, max_length=50)), + ], + options={ + 'ordering': ['parsed_date'], + }, + ), + migrations.CreateModel( + name='InboundEvent', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('source_system', models.CharField(choices=[('his', 'Hospital Information System'), ('lab', 'Laboratory System'), ('radiology', 'Radiology System'), ('pharmacy', 'Pharmacy System'), ('moh', 'Ministry of Health'), ('chi', 'Council of Health Insurance'), ('pxconnect', 'PX Connect'), ('other', 'Other')], db_index=True, help_text='System that sent this event', max_length=50)), + ('event_code', models.CharField(db_index=True, help_text='Event type code (e.g., OPD_VISIT_COMPLETED, LAB_ORDER_COMPLETED)', max_length=100)), + ('encounter_id', models.CharField(db_index=True, help_text='Encounter ID from HIS system', max_length=100)), + ('patient_identifier', models.CharField(blank=True, db_index=True, help_text='Patient MRN or other identifier', max_length=100)), + ('payload_json', models.JSONField(help_text='Full event payload from source system')), + ('status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('processed', 'Processed'), ('failed', 'Failed'), ('ignored', 'Ignored')], db_index=True, default='pending', max_length=20)), + ('received_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('processed_at', models.DateTimeField(blank=True, null=True)), + ('error', models.TextField(blank=True, help_text='Error message if processing failed')), + ('processing_attempts', models.IntegerField(default=0, help_text='Number of processing attempts')), + ('physician_license', models.CharField(blank=True, help_text='Physician license number from event', max_length=100)), + ('department_code', models.CharField(blank=True, help_text='Department code from event', max_length=50)), + ('metadata', models.JSONField(blank=True, default=dict, help_text='Additional processing metadata')), + ], + options={ + 'ordering': ['-received_at'], + }, + ), + migrations.CreateModel( + name='IntegrationConfig', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200, unique=True)), + ('source_system', models.CharField(choices=[('his', 'Hospital Information System'), ('lab', 'Laboratory System'), ('radiology', 'Radiology System'), ('pharmacy', 'Pharmacy System'), ('moh', 'Ministry of Health'), ('chi', 'Council of Health Insurance'), ('pxconnect', 'PX Connect'), ('other', 'Other')], max_length=50, unique=True)), + ('api_url', models.URLField(blank=True, help_text='API endpoint URL')), + ('api_key', models.CharField(blank=True, help_text='API key (encrypted)', max_length=500)), + ('is_active', models.BooleanField(default=True)), + ('config_json', models.JSONField(blank=True, default=dict, help_text='Additional configuration (event mappings, field mappings, etc.)')), + ('description', models.TextField(blank=True)), + ('last_sync_at', models.DateTimeField(blank=True, null=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='SurveyTemplateMapping', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('patient_type', models.CharField(choices=[('IP', 'Inpatient'), ('OP', 'Outpatient'), ('ED', 'Emergency'), ('DAYCASE', 'Day Case'), ('APPOINTMENT', 'Appointment')], db_index=True, help_text='Patient type from HIS system', max_length=20)), + ('is_active', models.BooleanField(db_index=True, default=True, help_text='Whether this mapping is active')), + ('send_delay_hours', models.IntegerField(default=1, help_text='Hours after discharge to send survey')), + ], + options={ + 'ordering': ['hospital', 'patient_type'], + }, + ), + migrations.CreateModel( + name='HISPatientVisit', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('admission_id', models.CharField(db_index=True, max_length=100)), + ('reg_code', models.CharField(blank=True, db_index=True, max_length=100)), + ('patient_id_his', models.CharField(blank=True, db_index=True, help_text='PatientID from HIS', max_length=100)), + ('patient_type', models.CharField(help_text='ED, IP, OP from HIS', max_length=10)), + ('admit_date', models.DateTimeField(blank=True, null=True)), + ('discharge_date', models.DateTimeField(blank=True, help_text='From HIS DischargeDate (null for OP)', null=True)), + ('effective_discharge_date', models.DateTimeField(blank=True, help_text='For OP: last visit timestamp when deemed complete', null=True)), + ('visit_data', models.JSONField(blank=True, default=dict, help_text='Full patient demographic dict from HIS')), + ('visit_timeline', models.JSONField(blank=True, default=list, help_text='Extracted visit events for this patient')), + ('primary_doctor', models.CharField(blank=True, help_text='From HIS PrimaryDoctor (raw text fallback)', max_length=300)), + ('consultant_id', models.CharField(blank=True, help_text='From HIS ConsultantID (raw text fallback)', max_length=50)), + ('company_name', models.CharField(blank=True, help_text='From HIS CompanyName (insurance sponsor)', max_length=300)), + ('grade_name', models.CharField(blank=True, help_text='From HIS GradeName (insurance grade)', max_length=100)), + ('insurance_company_name', models.CharField(blank=True, help_text='From HIS InsuranceCompanyName', max_length=300)), + ('bill_type', models.CharField(blank=True, help_text='From HIS BillType (CS/CR)', max_length=20)), + ('is_vip', models.BooleanField(db_index=True, default=False, help_text='From HIS IsVIP')), + ('nationality', models.CharField(blank=True, help_text='From HIS PatientNationality', max_length=100)), + ('is_visit_complete', models.BooleanField(db_index=True, default=False)), + ('last_his_fetch_at', models.DateTimeField(blank=True, help_text='Last time this visit was seen in a HIS fetch', null=True)), + ('consultant_fk', models.ForeignKey(blank=True, help_text='Resolved Staff record from ConsultantID', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='his_visits_as_consultant', to='organizations.staff')), + ('hospital', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='his_visits', to='organizations.hospital')), + ('patient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='his_visits', to='organizations.patient')), + ('primary_doctor_fk', models.ForeignKey(blank=True, help_text='Resolved Staff record from PrimaryDoctor ID prefix', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='his_visits_as_doctor', to='organizations.staff')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/apps/integrations/migrations/0002_initial.py b/apps/integrations/migrations/0002_initial.py new file mode 100644 index 0000000..51190bf --- /dev/null +++ b/apps/integrations/migrations/0002_initial.py @@ -0,0 +1,123 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('integrations', '0001_initial'), + ('organizations', '0001_initial'), + ('surveys', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='hispatientvisit', + name='survey_instance', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='his_visit', to='surveys.surveyinstance'), + ), + migrations.AddIndex( + model_name='histestpatient', + index=models.Index(fields=['patient_type', 'admit_date'], name='integration_patient_d70ddd_idx'), + ), + migrations.AddIndex( + model_name='histestpatient', + index=models.Index(fields=['ssn', 'admit_date'], name='integration_ssn_53c08e_idx'), + ), + migrations.AddIndex( + model_name='histestpatient', + index=models.Index(fields=['mobile_no', 'admit_date'], name='integration_mobile__a8a578_idx'), + ), + migrations.AddConstraint( + model_name='histestpatient', + constraint=models.UniqueConstraint(fields=('admission_id',), name='unique_test_patient_admission'), + ), + migrations.AddIndex( + model_name='histestvisit', + index=models.Index(fields=['admission_id', 'visit_category'], name='integration_admissi_948163_idx'), + ), + migrations.AddIndex( + model_name='histestvisit', + index=models.Index(fields=['patient_id', 'visit_category'], name='integration_patient_6151c1_idx'), + ), + migrations.AddIndex( + model_name='histestvisit', + index=models.Index(fields=['admission_id', 'bill_date'], name='integration_admissi_23d141_idx'), + ), + migrations.AddField( + model_name='hisvisitevent', + name='visit', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='visit_events', to='integrations.hispatientvisit'), + ), + migrations.AddIndex( + model_name='inboundevent', + index=models.Index(fields=['status', '-received_at'], name='integration_status_f5244c_idx'), + ), + migrations.AddIndex( + model_name='inboundevent', + index=models.Index(fields=['encounter_id', 'event_code'], name='integration_encount_e7d795_idx'), + ), + migrations.AddIndex( + model_name='inboundevent', + index=models.Index(fields=['source_system', '-received_at'], name='integration_source__bacde5_idx'), + ), + migrations.AddField( + model_name='eventmapping', + name='integration_config', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_mappings', to='integrations.integrationconfig'), + ), + migrations.AddField( + model_name='surveytemplatemapping', + name='hospital', + field=models.ForeignKey(blank=True, help_text='Hospital for this mapping (null = applies to all hospitals)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='survey_template_mappings', to='organizations.hospital'), + ), + migrations.AddField( + model_name='surveytemplatemapping', + name='survey_template', + field=models.ForeignKey(help_text='Survey template to send for this patient type', on_delete=django.db.models.deletion.CASCADE, related_name='patient_type_mappings', to='surveys.surveytemplate'), + ), + migrations.AddIndex( + model_name='hispatientvisit', + index=models.Index(fields=['patient_type', 'is_visit_complete'], name='integration_patient_89c76d_idx'), + ), + migrations.AddIndex( + model_name='hispatientvisit', + index=models.Index(fields=['patient_type', 'last_his_fetch_at'], name='integration_patient_1e82ed_idx'), + ), + migrations.AddIndex( + model_name='hispatientvisit', + index=models.Index(fields=['admission_id', 'is_visit_complete'], name='integration_admissi_a63b9a_idx'), + ), + migrations.AddIndex( + model_name='hispatientvisit', + index=models.Index(fields=['hospital', 'patient_type', 'is_visit_complete'], name='integration_hospita_1e17bb_idx'), + ), + migrations.AddConstraint( + model_name='hispatientvisit', + constraint=models.UniqueConstraint(fields=('admission_id',), name='unique_his_visit_per_admission'), + ), + migrations.AddIndex( + model_name='hisvisitevent', + index=models.Index(fields=['visit', 'parsed_date'], name='integration_visit_i_901d6c_idx'), + ), + migrations.AddIndex( + model_name='hisvisitevent', + index=models.Index(fields=['visit_category', 'parsed_date'], name='integration_visit_c_0a2be5_idx'), + ), + migrations.AlterUniqueTogether( + name='eventmapping', + unique_together={('integration_config', 'external_event_code')}, + ), + migrations.AddIndex( + model_name='surveytemplatemapping', + index=models.Index(fields=['patient_type', 'hospital', 'is_active'], name='integration_patient_3e4ef8_idx'), + ), + migrations.AddConstraint( + model_name='surveytemplatemapping', + constraint=models.UniqueConstraint(condition=models.Q(('is_active', True)), fields=('patient_type', 'hospital'), name='unique_active_mapping_per_type_hospital'), + ), + ] diff --git a/apps/journeys/migrations/0001_initial.py b/apps/journeys/migrations/0001_initial.py new file mode 100644 index 0000000..2b28c08 --- /dev/null +++ b/apps/journeys/migrations/0001_initial.py @@ -0,0 +1,90 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('organizations', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='PatientJourneyStageTemplate', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200)), + ('name_ar', models.CharField(blank=True, max_length=200)), + ('code', models.CharField(help_text='Unique code for this stage (e.g., OPD_MD_CONSULT, LAB, RADIOLOGY)', max_length=50)), + ('order', models.IntegerField(default=0, help_text='Order of this stage in the journey')), + ('trigger_event_code', models.CharField(db_index=True, help_text='Event code that triggers completion of this stage (e.g., OPD_VISIT_COMPLETED)', max_length=100)), + ('is_optional', models.BooleanField(default=False, help_text='Can this stage be skipped?')), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'ordering': ['journey_template', 'order'], + }, + ), + migrations.CreateModel( + name='PatientJourneyTemplate', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200)), + ('name_ar', models.CharField(blank=True, max_length=200, verbose_name='Name (Arabic)')), + ('journey_type', models.CharField(choices=[('ems', 'EMS (Emergency Medical Services)'), ('inpatient', 'Inpatient'), ('opd', 'OPD (Outpatient Department)'), ('day_case', 'Day Case')], db_index=True, max_length=20)), + ('description', models.TextField(blank=True)), + ('is_active', models.BooleanField(db_index=True, default=True)), + ('is_default', models.BooleanField(default=False, help_text='Default template for this journey type in this hospital')), + ('send_post_discharge_survey', models.BooleanField(default=False, help_text='Send a comprehensive survey after patient discharge')), + ('post_discharge_survey_delay_hours', models.IntegerField(default=1, help_text='Hours after discharge to send the survey')), + ], + options={ + 'ordering': ['hospital', 'journey_type', 'name'], + }, + ), + migrations.CreateModel( + name='PatientJourneyInstance', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('encounter_id', models.CharField(help_text='Unique encounter ID from HIS system', max_length=100, unique=True)), + ('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='active', max_length=20)), + ('started_at', models.DateTimeField(auto_now_add=True)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ('metadata', models.JSONField(blank=True, default=dict, help_text='Additional metadata from HIS system')), + ('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='journey_instances', to='organizations.department')), + ('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='journey_instances', to='organizations.hospital')), + ('patient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='journeys', to='organizations.patient')), + ], + options={ + 'ordering': ['-started_at'], + }, + ), + migrations.CreateModel( + name='PatientJourneyStageInstance', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('in_progress', 'In Progress'), ('completed', 'Completed'), ('skipped', 'Skipped'), ('cancelled', 'Cancelled')], db_index=True, default='pending', max_length=20)), + ('completed_at', models.DateTimeField(blank=True, db_index=True, null=True)), + ('metadata', models.JSONField(blank=True, default=dict, help_text='Additional data from integration event')), + ('department', models.ForeignKey(blank=True, help_text='Department where this stage occurred', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='journey_stages', to='organizations.department')), + ('journey_instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stage_instances', to='journeys.patientjourneyinstance')), + ('staff', models.ForeignKey(blank=True, help_text='Staff member associated with this stage', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='journey_stages', to='organizations.staff')), + ], + options={ + 'ordering': ['journey_instance', 'stage_template__order'], + }, + ), + ] diff --git a/apps/journeys/migrations/0002_initial.py b/apps/journeys/migrations/0002_initial.py new file mode 100644 index 0000000..9264030 --- /dev/null +++ b/apps/journeys/migrations/0002_initial.py @@ -0,0 +1,79 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('journeys', '0001_initial'), + ('organizations', '0001_initial'), + ('surveys', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='patientjourneystagetemplate', + name='survey_template', + field=models.ForeignKey(blank=True, help_text='Survey template containing questions for this stage (merged into post-discharge survey)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='journey_stages', to='surveys.surveytemplate'), + ), + migrations.AddField( + model_name='patientjourneystageinstance', + name='stage_template', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='instances', to='journeys.patientjourneystagetemplate'), + ), + migrations.AddField( + model_name='patientjourneytemplate', + name='hospital', + field=models.ForeignKey(help_text='Hospital this template belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='journey_templates', to='organizations.hospital'), + ), + migrations.AddField( + model_name='patientjourneystagetemplate', + name='journey_template', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stages', to='journeys.patientjourneytemplate'), + ), + migrations.AddField( + model_name='patientjourneyinstance', + name='journey_template', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='instances', to='journeys.patientjourneytemplate'), + ), + migrations.AddIndex( + model_name='patientjourneystageinstance', + index=models.Index(fields=['journey_instance', 'status'], name='journeys_pa_journey_dc3289_idx'), + ), + migrations.AddIndex( + model_name='patientjourneystageinstance', + index=models.Index(fields=['status', 'completed_at'], name='journeys_pa_status_563c5f_idx'), + ), + migrations.AlterUniqueTogether( + name='patientjourneystageinstance', + unique_together={('journey_instance', 'stage_template')}, + ), + migrations.AddIndex( + model_name='patientjourneytemplate', + index=models.Index(fields=['hospital', 'journey_type', 'is_active'], name='journeys_pa_hospita_3b6b47_idx'), + ), + migrations.AlterUniqueTogether( + name='patientjourneytemplate', + unique_together={('hospital', 'journey_type', 'name')}, + ), + migrations.AddIndex( + model_name='patientjourneystagetemplate', + index=models.Index(fields=['journey_template', 'order'], name='journeys_pa_journey_ded883_idx'), + ), + migrations.AlterUniqueTogether( + name='patientjourneystagetemplate', + unique_together={('journey_template', 'code')}, + ), + migrations.AddIndex( + model_name='patientjourneyinstance', + index=models.Index(fields=['patient', '-started_at'], name='journeys_pa_patient_174f56_idx'), + ), + migrations.AddIndex( + model_name='patientjourneyinstance', + index=models.Index(fields=['hospital', 'status', '-started_at'], name='journeys_pa_hospita_724af9_idx'), + ), + ] diff --git a/apps/notifications/migrations/0001_initial.py b/apps/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..e6e9094 --- /dev/null +++ b/apps/notifications/migrations/0001_initial.py @@ -0,0 +1,211 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('organizations', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='NotificationTemplate', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200, unique=True)), + ('description', models.TextField(blank=True)), + ('template_type', models.CharField(choices=[('survey_invitation', 'Survey Invitation'), ('survey_reminder', 'Survey Reminder'), ('complaint_acknowledgment', 'Complaint Acknowledgment'), ('complaint_update', 'Complaint Update'), ('action_assignment', 'Action Assignment'), ('sla_reminder', 'SLA Reminder'), ('sla_breach', 'SLA Breach'), ('onboarding_invitation', 'Onboarding Invitation'), ('onboarding_reminder', 'Onboarding Reminder'), ('onboarding_completion', 'Onboarding Completion')], db_index=True, max_length=50)), + ('sms_template', models.TextField(blank=True, help_text='SMS template with {{variables}}')), + ('sms_template_ar', models.TextField(blank=True)), + ('whatsapp_template', models.TextField(blank=True, help_text='WhatsApp template with {{variables}}')), + ('whatsapp_template_ar', models.TextField(blank=True)), + ('email_subject', models.CharField(blank=True, max_length=500)), + ('email_subject_ar', models.CharField(blank=True, max_length=500)), + ('email_template', models.TextField(blank=True, help_text='Email HTML template with {{variables}}')), + ('email_template_ar', models.TextField(blank=True)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='HospitalNotificationSettings', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('complaint_acknowledgment_email', models.BooleanField(default=True, help_text='Send email when complaint is acknowledged')), + ('complaint_acknowledgment_sms', models.BooleanField(default=False, help_text='Send SMS when complaint is acknowledged')), + ('complaint_acknowledgment_whatsapp', models.BooleanField(default=False, help_text='Send WhatsApp when complaint is acknowledged')), + ('complaint_assigned_email', models.BooleanField(default=True, help_text='Send email when complaint is assigned')), + ('complaint_assigned_sms', models.BooleanField(default=False, help_text='Send SMS when complaint is assigned')), + ('complaint_assigned_whatsapp', models.BooleanField(default=False, help_text='Send WhatsApp when complaint is assigned')), + ('complaint_status_changed_email', models.BooleanField(default=True, help_text='Send email when complaint status changes')), + ('complaint_status_changed_sms', models.BooleanField(default=False, help_text='Send SMS when complaint status changes')), + ('complaint_status_changed_whatsapp', models.BooleanField(default=False, help_text='Send WhatsApp when complaint status changes')), + ('complaint_resolved_email', models.BooleanField(default=True, help_text='Send email when complaint is resolved')), + ('complaint_resolved_sms', models.BooleanField(default=False, help_text='Send SMS when complaint is resolved')), + ('complaint_resolved_whatsapp', models.BooleanField(default=False, help_text='Send WhatsApp when complaint is resolved')), + ('complaint_closed_email', models.BooleanField(default=True, help_text='Send email when complaint is closed')), + ('complaint_closed_sms', models.BooleanField(default=False, help_text='Send SMS when complaint is closed')), + ('complaint_closed_whatsapp', models.BooleanField(default=False, help_text='Send WhatsApp when complaint is closed')), + ('explanation_requested_email', models.BooleanField(default=True, help_text='Send email when complaint is sent to department')), + ('explanation_requested_sms', models.BooleanField(default=False, help_text='Send SMS when explanation is requested from staff')), + ('explanation_requested_whatsapp', models.BooleanField(default=False, help_text='Send WhatsApp when explanation is requested from staff')), + ('explanation_reminder_email', models.BooleanField(default=True, help_text='Send email reminder at 24h before SLA expires')), + ('explanation_reminder_sms', models.BooleanField(default=False, help_text='Send SMS reminder at 24h before SLA expires')), + ('explanation_reminder_whatsapp', models.BooleanField(default=False, help_text='Send WhatsApp reminder at 24h before SLA expires')), + ('explanation_overdue_email', models.BooleanField(default=True, help_text='Send email when explanation is overdue (escalation)')), + ('explanation_overdue_sms', models.BooleanField(default=False, help_text='Send SMS when explanation is overdue (escalation)')), + ('explanation_overdue_whatsapp', models.BooleanField(default=False, help_text='Send WhatsApp when explanation is overdue (escalation)')), + ('explanation_received_email', models.BooleanField(default=True, help_text='Send email when staff submits explanation')), + ('explanation_received_sms', models.BooleanField(default=False, help_text='Send SMS when staff submits explanation')), + ('explanation_received_whatsapp', models.BooleanField(default=False, help_text='Send WhatsApp when staff submits explanation')), + ('explanation_manager_cc', models.BooleanField(default=False, help_text='CC manager when explanation is requested from staff')), + ('survey_invitation_email', models.BooleanField(default=True, help_text='Send email survey invitation to patient')), + ('survey_invitation_sms', models.BooleanField(default=True, help_text='Send SMS survey invitation to patient')), + ('survey_invitation_whatsapp', models.BooleanField(default=False, help_text='Send WhatsApp survey invitation to patient')), + ('survey_reminder_email', models.BooleanField(default=True, help_text='Send email survey reminder')), + ('survey_reminder_sms', models.BooleanField(default=True, help_text='Send SMS survey reminder')), + ('survey_reminder_whatsapp', models.BooleanField(default=False, help_text='Send WhatsApp survey reminder')), + ('survey_completed_email', models.BooleanField(default=True, help_text='Send email when patient completes survey')), + ('survey_completed_sms', models.BooleanField(default=False, help_text='Send SMS when patient completes survey')), + ('action_assigned_email', models.BooleanField(default=True, help_text='Send email when action is assigned')), + ('action_assigned_sms', models.BooleanField(default=False, help_text='Send SMS when action is assigned')), + ('action_assigned_whatsapp', models.BooleanField(default=False, help_text='Send WhatsApp when action is assigned')), + ('action_due_soon_email', models.BooleanField(default=True, help_text='Send email when action is due soon')), + ('action_due_soon_sms', models.BooleanField(default=False, help_text='Send SMS when action is due soon')), + ('action_overdue_email', models.BooleanField(default=True, help_text='Send email when action is overdue')), + ('action_overdue_sms', models.BooleanField(default=False, help_text='Send SMS when action is overdue')), + ('sla_reminder_email', models.BooleanField(default=True, help_text='Send email SLA reminder')), + ('sla_reminder_sms', models.BooleanField(default=False, help_text='Send SMS SLA reminder')), + ('sla_breach_email', models.BooleanField(default=True, help_text='Send email when SLA is breached')), + ('sla_breach_sms', models.BooleanField(default=False, help_text='Send SMS when SLA is breached')), + ('sla_breach_whatsapp', models.BooleanField(default=False, help_text='Send WhatsApp when SLA is breached')), + ('onboarding_invitation_email', models.BooleanField(default=True, help_text='Send email invitation to new provisional users')), + ('onboarding_invitation_sms', models.BooleanField(default=False, help_text='Send SMS invitation to new provisional users')), + ('onboarding_invitation_whatsapp', models.BooleanField(default=False, help_text='Send WhatsApp invitation to new provisional users')), + ('onboarding_reminder_email', models.BooleanField(default=True, help_text='Send email reminder to complete onboarding')), + ('onboarding_reminder_sms', models.BooleanField(default=False, help_text='Send SMS reminder to complete onboarding')), + ('onboarding_reminder_whatsapp', models.BooleanField(default=False, help_text='Send WhatsApp reminder to complete onboarding')), + ('onboarding_completion_email', models.BooleanField(default=True, help_text='Send email notification to admins when user completes onboarding')), + ('onboarding_completion_sms', models.BooleanField(default=False, help_text='Send SMS notification to admins when user completes onboarding')), + ('notifications_enabled', models.BooleanField(default=True, help_text='Master switch to enable/disable all notifications')), + ('quiet_hours_enabled', models.BooleanField(default=False, help_text='Enable quiet hours (no SMS/WhatsApp during these hours)')), + ('quiet_hours_start', models.TimeField(default='22:00', help_text='Start of quiet hours (e.g., 22:00 for 10 PM)')), + ('quiet_hours_end', models.TimeField(default='08:00', help_text='End of quiet hours (e.g., 08:00 for 8 AM)')), + ('retry_failed_notifications', models.BooleanField(default=True, help_text='Automatically retry failed notifications')), + ('max_retries', models.IntegerField(default=3, help_text='Maximum number of retry attempts')), + ('hospital', models.OneToOneField(help_text='Hospital these settings apply to', on_delete=django.db.models.deletion.CASCADE, related_name='notification_settings', to='organizations.hospital')), + ], + options={ + 'verbose_name': 'Hospital Notification Settings', + 'verbose_name_plural': 'Hospital Notification Settings', + }, + ), + migrations.CreateModel( + name='NotificationLog', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('channel', models.CharField(choices=[('sms', 'SMS'), ('whatsapp', 'WhatsApp'), ('email', 'Email'), ('push', 'Push Notification')], db_index=True, max_length=20)), + ('recipient', models.CharField(help_text='Phone number or email address', max_length=200)), + ('subject', models.CharField(blank=True, max_length=500)), + ('message', models.TextField()), + ('object_id', models.UUIDField(blank=True, null=True)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('sending', 'Sending'), ('sent', 'Sent'), ('delivered', 'Delivered'), ('failed', 'Failed'), ('bounced', 'Bounced')], db_index=True, default='pending', max_length=20)), + ('sent_at', models.DateTimeField(blank=True, null=True)), + ('delivered_at', models.DateTimeField(blank=True, null=True)), + ('provider', models.CharField(blank=True, help_text='SMS/Email provider used', max_length=50)), + ('provider_message_id', models.CharField(blank=True, help_text='Message ID from provider', max_length=200)), + ('provider_response', models.JSONField(blank=True, default=dict, help_text='Full response from provider')), + ('error', models.TextField(blank=True)), + ('retry_count', models.IntegerField(default=0)), + ('metadata', models.JSONField(blank=True, default=dict, help_text='Additional metadata (campaign, template, etc.)')), + ('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='contenttypes.contenttype')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='NotificationSettingsLog', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('field_name', models.CharField(max_length=100)), + ('old_value', models.BooleanField(null=True)), + ('new_value', models.BooleanField(null=True)), + ('changed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_settings_logs', to='organizations.hospital')), + ], + options={ + 'verbose_name': 'Notification Settings Change Log', + 'verbose_name_plural': 'Notification Settings Change Logs', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='UserNotification', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('title', models.CharField(max_length=200)), + ('title_ar', models.CharField(blank=True, max_length=200)), + ('message', models.TextField()), + ('message_ar', models.TextField(blank=True)), + ('notification_type', models.CharField(max_length=50)), + ('object_id', models.UUIDField(blank=True, null=True)), + ('action_url', models.CharField(blank=True, max_length=500)), + ('is_read', models.BooleanField(default=False)), + ('read_at', models.DateTimeField(blank=True, null=True)), + ('is_dismissed', models.BooleanField(default=False)), + ('dismissed_at', models.DateTimeField(blank=True, null=True)), + ('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('email_log', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='user_notification', to='notifications.notificationlog')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.AddIndex( + model_name='notificationlog', + index=models.Index(fields=['channel', 'status', '-created_at'], name='notificatio_channel_b100a4_idx'), + ), + migrations.AddIndex( + model_name='notificationlog', + index=models.Index(fields=['recipient', '-created_at'], name='notificatio_recipie_d4670c_idx'), + ), + migrations.AddIndex( + model_name='notificationlog', + index=models.Index(fields=['content_type', 'object_id'], name='notificatio_content_bc6e15_idx'), + ), + migrations.AddIndex( + model_name='usernotification', + index=models.Index(fields=['user', 'is_dismissed', '-created_at'], name='notificatio_user_id_092141_idx'), + ), + migrations.AddIndex( + model_name='usernotification', + index=models.Index(fields=['user', 'is_read', '-created_at'], name='notificatio_user_id_bf9fcb_idx'), + ), + migrations.AddIndex( + model_name='usernotification', + index=models.Index(fields=['created_at'], name='notificatio_created_edf100_idx'), + ), + ] diff --git a/apps/observations/migrations/0001_initial.py b/apps/observations/migrations/0001_initial.py new file mode 100644 index 0000000..7802b40 --- /dev/null +++ b/apps/observations/migrations/0001_initial.py @@ -0,0 +1,208 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import apps.observations.models +import django.db.models.deletion +import django.utils.timezone +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('organizations', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ObservationAttachment', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('file', models.FileField(help_text='Uploaded file', upload_to='observations/%Y/%m/%d/')), + ('filename', models.CharField(blank=True, max_length=500)), + ('file_type', models.CharField(blank=True, max_length=100)), + ('file_size', models.IntegerField(default=0, help_text='File size in bytes')), + ('description', models.CharField(blank=True, max_length=500)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ObservationCategory', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name_en', models.CharField(max_length=200, verbose_name='Name (English)')), + ('name_ar', models.CharField(blank=True, max_length=200, verbose_name='Name (Arabic)')), + ('description', models.TextField(blank=True)), + ('is_active', models.BooleanField(db_index=True, default=True)), + ('sort_order', models.IntegerField(default=0, help_text='Lower numbers appear first')), + ('icon', models.CharField(blank=True, help_text='Bootstrap icon class', max_length=50)), + ], + options={ + 'verbose_name': 'Observation Category', + 'verbose_name_plural': 'Observation Categories', + 'ordering': ['sort_order', 'name_en'], + }, + ), + migrations.CreateModel( + name='ObservationNote', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('note', models.TextField()), + ('is_internal', models.BooleanField(default=True, help_text='Internal notes are not visible to public')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ObservationSLAConfig', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('severity', models.CharField(blank=True, choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], help_text='Severity level for this SLA (optional = default config)', max_length=20, null=True)), + ('sla_hours', models.IntegerField(help_text='Number of hours until SLA deadline')), + ('first_reminder_hours_after', models.IntegerField(default=0, help_text='Send 1st reminder X hours after observation creation (0 = use reminder_hours_before)')), + ('second_reminder_hours_after', models.IntegerField(default=0, help_text='Send 2nd reminder X hours after observation creation (0 = use second_reminder_hours_before)')), + ('escalation_hours_after', models.IntegerField(default=0, help_text='Escalate observation X hours after creation if unresolved (0 = use overdue logic)')), + ('reminder_hours_before', models.IntegerField(default=24, help_text='Send first reminder X hours before deadline')), + ('second_reminder_enabled', models.BooleanField(default=False, help_text='Enable sending a second reminder')), + ('second_reminder_hours_before', models.IntegerField(default=6, help_text='Send second reminder X hours before deadline')), + ('is_active', models.BooleanField(default=True)), + ('dept_response_hours', models.IntegerField(default=48, help_text='Hours for department to submit a response')), + ('dept_response_reminder_hours_before', models.IntegerField(default=12, help_text='Send 1st reminder X hours before dept response deadline')), + ('dept_response_second_reminder_enabled', models.BooleanField(default=True, help_text='Enable sending a second reminder for dept response')), + ('dept_response_second_reminder_hours_before', models.IntegerField(default=4, help_text='Send 2nd reminder X hours before dept response deadline')), + ('dept_response_auto_escalate_enabled', models.BooleanField(default=True, help_text='Auto-escalate to department manager if response overdue')), + ('dept_response_escalation_hours_overdue', models.IntegerField(default=0, help_text='Escalate X hours after dept response deadline (0 = immediately)')), + ], + options={ + 'verbose_name': 'Observation SLA Config', + 'verbose_name_plural': 'Observation SLA Configs', + 'ordering': ['hospital', 'severity'], + }, + ), + migrations.CreateModel( + name='ObservationStatusLog', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('from_status', models.CharField(blank=True, choices=[('new', 'New'), ('triaged', 'Triaged'), ('assigned', 'Assigned'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed'), ('rejected', 'Rejected'), ('duplicate', 'Duplicate'), ('contacted', 'Contacted'), ('contacted_no_response', 'Contacted, No Response')], max_length=25)), + ('to_status', models.CharField(choices=[('new', 'New'), ('triaged', 'Triaged'), ('assigned', 'Assigned'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed'), ('rejected', 'Rejected'), ('duplicate', 'Duplicate'), ('contacted', 'Contacted'), ('contacted_no_response', 'Contacted, No Response')], max_length=25)), + ('comment', models.TextField(blank=True, help_text='Optional comment about the status change')), + ], + options={ + 'verbose_name': 'Observation Status Log', + 'verbose_name_plural': 'Observation Status Logs', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ObservationSubCategory', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name_en', models.CharField(max_length=200, verbose_name='Name (English)')), + ('name_ar', models.CharField(blank=True, max_length=200, verbose_name='Name (Arabic)')), + ('description', models.TextField(blank=True)), + ('is_active', models.BooleanField(db_index=True, default=True)), + ('sort_order', models.IntegerField(default=0, help_text='Lower numbers appear first')), + ], + options={ + 'verbose_name': 'Observation Sub-Category', + 'verbose_name_plural': 'Observation Sub-Categories', + 'ordering': ['sort_order', 'name_en'], + }, + ), + migrations.CreateModel( + name='Observation', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('is_deleted', models.BooleanField(db_index=True, default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('tracking_code', models.CharField(default=apps.observations.models.generate_tracking_code, help_text='Unique code for tracking this observation', max_length=20, unique=True)), + ('title', models.CharField(blank=True, help_text='Optional short title', max_length=300)), + ('description', models.TextField(help_text='Detailed description of the observation')), + ('description_en', models.TextField(blank=True, help_text='English description of the observation')), + ('severity', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], db_index=True, default='medium', max_length=20)), + ('location_text', models.CharField(blank=True, help_text='Where the issue was observed (building, floor, room, etc.)', max_length=500)), + ('incident_datetime', models.DateTimeField(default=django.utils.timezone.now, help_text='When the issue was observed')), + ('reporter_staff_id', models.CharField(blank=True, help_text='Optional staff ID of the reporter', max_length=50)), + ('reporter_name', models.CharField(blank=True, help_text='Optional name of the reporter', max_length=200)), + ('reporter_phone', models.CharField(blank=True, help_text='Optional phone number for follow-up', max_length=20)), + ('reporter_email', models.EmailField(blank=True, help_text='Optional email for follow-up', max_length=254)), + ('patient_file_number', models.CharField(blank=True, help_text='Medical record number / file number of the patient', max_length=100)), + ('status', models.CharField(choices=[('new', 'New'), ('triaged', 'Triaged'), ('assigned', 'Assigned'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed'), ('rejected', 'Rejected'), ('duplicate', 'Duplicate'), ('contacted', 'Contacted'), ('contacted_no_response', 'Contacted, No Response')], db_index=True, default='new', max_length=25)), + ('source', models.CharField(choices=[('staff_portal', 'Staff Portal'), ('web_form', 'Web Form'), ('mobile_app', 'Mobile App'), ('email', 'Email'), ('call_center', 'Call Center'), ('other', 'Other')], default='staff_portal', help_text='How the observation was submitted (legacy)', max_length=50)), + ('assigned_at', models.DateTimeField(blank=True, null=True)), + ('person_noted', models.CharField(blank=True, help_text='Person who was informed/notified about the observation', max_length=300)), + ('communication_method', models.CharField(blank=True, help_text='How communication was made (extension, mobile, office, etc.)', max_length=50)), + ('communication_datetime', models.DateTimeField(blank=True, help_text='When the person/department was contacted', null=True)), + ('activated_at', models.DateTimeField(blank=True, db_index=True, help_text='Timestamp when observation was first activated (moved to IN_PROGRESS)', null=True)), + ('due_at', models.DateTimeField(blank=True, db_index=True, help_text='SLA deadline', null=True)), + ('is_overdue', models.BooleanField(db_index=True, default=False)), + ('breached_at', models.DateTimeField(blank=True, db_index=True, help_text='Timestamp when observation first breached SLA', null=True)), + ('reminder_sent_at', models.DateTimeField(blank=True, help_text='First SLA reminder timestamp', null=True)), + ('second_reminder_sent_at', models.DateTimeField(blank=True, help_text='Second SLA reminder timestamp', null=True)), + ('escalated_at', models.DateTimeField(blank=True, null=True)), + ('triaged_at', models.DateTimeField(blank=True, null=True)), + ('department_response_en', models.TextField(blank=True, verbose_name='Department Response (English)')), + ('department_response_ar', models.TextField(blank=True, verbose_name='Department Response (Arabic)')), + ('department_response_summary_en', models.TextField(blank=True, verbose_name='AI Summary of Dept Response (EN)')), + ('department_response_summary_ar', models.TextField(blank=True, verbose_name='AI Summary of Dept Response (AR)')), + ('department_responded_at', models.DateTimeField(blank=True, null=True)), + ('forwarded_to_dept_at', models.DateTimeField(blank=True, help_text='When the observation was sent to the department', null=True)), + ('dept_response_sla_due_at', models.DateTimeField(blank=True, db_index=True, help_text='SLA deadline for department response', null=True)), + ('dept_response_is_overdue', models.BooleanField(db_index=True, default=False, help_text='Department response is overdue')), + ('dept_response_reminder_sent_at', models.DateTimeField(blank=True, help_text='First SLA reminder for dept response', null=True)), + ('dept_response_second_reminder_sent_at', models.DateTimeField(blank=True, help_text='Second SLA reminder for dept response', null=True)), + ('dept_response_escalated_at', models.DateTimeField(blank=True, help_text='When dept response was escalated to manager', null=True)), + ('dept_response_acceptance_status', models.CharField(choices=[('pending', 'Pending Review'), ('acceptable', 'Acceptable'), ('not_acceptable', 'Not Acceptable')], default='pending', help_text='Review status of the department response', max_length=20)), + ('dept_response_accepted_at', models.DateTimeField(blank=True, help_text='When the department response was reviewed', null=True)), + ('dept_response_acceptance_notes', models.TextField(blank=True, help_text='Notes about the acceptance decision')), + ('resolved_at', models.DateTimeField(blank=True, null=True)), + ('resolution_notes', models.TextField(blank=True)), + ('closed_at', models.DateTimeField(blank=True, null=True)), + ('action_id', models.UUIDField(blank=True, help_text='ID of linked PX Action if converted', null=True)), + ('client_ip', models.GenericIPAddressField(blank=True, null=True)), + ('user_agent', models.TextField(blank=True)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('submitter_notified_at', models.DateTimeField(blank=True, help_text='When confirmation message was sent to submitter', null=True)), + ('responsible_person_notified_at', models.DateTimeField(blank=True, help_text='When email was sent to responsible person', null=True)), + ('monthly_follow_up_due_at', models.DateTimeField(blank=True, db_index=True, help_text='When monthly follow-up is due', null=True)), + ('monthly_follow_up_completed_at', models.DateTimeField(blank=True, help_text='When monthly follow-up was completed', null=True)), + ('monthly_follow_up_notes', models.TextField(blank=True, help_text='Notes from monthly follow-up')), + ('assigned_department', models.ForeignKey(blank=True, help_text='Department responsible for handling this observation', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_observations', to='organizations.department')), + ('assigned_to', models.ForeignKey(blank=True, help_text='User assigned to handle this observation', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_observations', to=settings.AUTH_USER_MODEL)), + ('closed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='closed_observations', to=settings.AUTH_USER_MODEL)), + ('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_%(class)s_set', to=settings.AUTH_USER_MODEL)), + ('department_noted', models.ForeignKey(blank=True, help_text='Department that was notified about this observation', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='noted_observations', to='organizations.department')), + ('department_responded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='department_observation_responses', to=settings.AUTH_USER_MODEL)), + ('dept_response_accepted_by', models.ForeignKey(blank=True, help_text='User who reviewed the department response', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_observation_dept_responses', to=settings.AUTH_USER_MODEL)), + ('hospital', models.ForeignKey(blank=True, help_text='Hospital where observation was made', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='observations', to='organizations.hospital')), + ('location', models.ForeignKey(blank=True, help_text='Location where the observation was made', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observations', to='organizations.location')), + ('main_section', models.ForeignKey(blank=True, help_text='Main section within the location', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observations', to='organizations.mainsection')), + ('monthly_follow_up_completed_by', models.ForeignKey(blank=True, help_text='User who completed the monthly follow-up', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='completed_observation_followups', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + 'permissions': [('triage_observation', 'Can triage observations'), ('manage_categories', 'Can manage observation categories')], + }, + ), + ] diff --git a/apps/observations/migrations/0002_initial.py b/apps/observations/migrations/0002_initial.py new file mode 100644 index 0000000..4d72fe3 --- /dev/null +++ b/apps/observations/migrations/0002_initial.py @@ -0,0 +1,135 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('complaints', '0003_initial'), + ('observations', '0001_initial'), + ('organizations', '0001_initial'), + ('px_sources', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='observation', + name='px_source', + field=models.ForeignKey(blank=True, help_text='Source of observation', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='observations', to='px_sources.pxsource'), + ), + migrations.AddField( + model_name='observation', + name='resolved_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='resolved_observations', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='observation', + name='staff', + field=models.ForeignKey(blank=True, help_text='Staff member mentioned in observation', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observations', to='organizations.staff'), + ), + migrations.AddField( + model_name='observation', + name='subsection', + field=models.ForeignKey(blank=True, help_text='Specific subsection', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observations', to='organizations.subsection'), + ), + migrations.AddField( + model_name='observation', + name='taxonomy_category', + field=models.ForeignKey(blank=True, help_text='Level 2: Category', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observations_category', to='complaints.complaintcategory'), + ), + migrations.AddField( + model_name='observation', + name='taxonomy_classification', + field=models.ForeignKey(blank=True, help_text='Level 4: Classification', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observations_classification', to='complaints.complaintcategory'), + ), + migrations.AddField( + model_name='observation', + name='taxonomy_domain', + field=models.ForeignKey(blank=True, help_text='Level 1: Domain', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observations_domain', to='complaints.complaintcategory'), + ), + migrations.AddField( + model_name='observation', + name='taxonomy_subcategory', + field=models.ForeignKey(blank=True, help_text='Level 3: Subcategory', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observations_subcategory', to='complaints.complaintcategory'), + ), + migrations.AddField( + model_name='observation', + name='triaged_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='triaged_observations', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='observationattachment', + name='observation', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='observations.observation'), + ), + migrations.AddField( + model_name='observation', + name='category', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observations', to='observations.observationcategory'), + ), + migrations.AddField( + model_name='observationnote', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observation_notes', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='observationnote', + name='observation', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='observations.observation'), + ), + migrations.AddField( + model_name='observationslaconfig', + name='hospital', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='observation_sla_configs', to='organizations.hospital'), + ), + migrations.AddField( + model_name='observationstatuslog', + name='changed_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observation_status_changes', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='observationstatuslog', + name='observation', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='status_logs', to='observations.observation'), + ), + migrations.AddField( + model_name='observationsubcategory', + name='category', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sub_categories', to='observations.observationcategory'), + ), + migrations.AddField( + model_name='observation', + name='sub_category', + field=models.ForeignKey(blank=True, help_text='Observation sub-category for finer classification', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observations', to='observations.observationsubcategory'), + ), + migrations.AddIndex( + model_name='observationslaconfig', + index=models.Index(fields=['hospital', 'is_active'], name='observation_hospita_5228b0_idx'), + ), + migrations.AddIndex( + model_name='observationslaconfig', + index=models.Index(fields=['hospital', 'severity', 'is_active'], name='observation_hospita_2cd15a_idx'), + ), + migrations.AddIndex( + model_name='observation', + index=models.Index(fields=['hospital', 'status', '-created_at'], name='observation_hospita_dcd21a_idx'), + ), + migrations.AddIndex( + model_name='observation', + index=models.Index(fields=['severity', '-created_at'], name='observation_severit_ba73c0_idx'), + ), + migrations.AddIndex( + model_name='observation', + index=models.Index(fields=['assigned_department', 'status'], name='observation_assigne_33edad_idx'), + ), + migrations.AddIndex( + model_name='observation', + index=models.Index(fields=['assigned_to', 'status'], name='observation_assigne_83ab1c_idx'), + ), + ] diff --git a/apps/organizations/migrations/0001_initial.py b/apps/organizations/migrations/0001_initial.py new file mode 100644 index 0000000..82558d2 --- /dev/null +++ b/apps/organizations/migrations/0001_initial.py @@ -0,0 +1,260 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import apps.core.encryption +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Location', + fields=[ + ('id', models.IntegerField(primary_key=True, serialize=False)), + ('name_ar', models.CharField(max_length=100)), + ('name_en', models.CharField(max_length=100)), + ], + ), + migrations.CreateModel( + name='MainSection', + fields=[ + ('id', models.IntegerField(primary_key=True, serialize=False)), + ('name_ar', models.CharField(max_length=100)), + ('name_en', models.CharField(max_length=100)), + ], + ), + migrations.CreateModel( + name='Organization', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200)), + ('name_ar', models.CharField(blank=True, max_length=200, verbose_name='Name (Arabic)')), + ('code', models.CharField(max_length=50, unique=True)), + ('phone', models.CharField(blank=True, max_length=20)), + ('email', models.EmailField(blank=True, max_length=254)), + ('address', models.TextField(blank=True)), + ('city', models.CharField(blank=True, max_length=100)), + ('preferred_language', models.CharField(choices=[('en', 'English'), ('ar', 'Arabic')], default='en', help_text='Preferred language for surveys and notifications', max_length=10)), + ('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='active', max_length=20)), + ], + options={ + 'verbose_name': 'Organization', + 'verbose_name_plural': 'Organizations', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Hospital', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200)), + ('name_ar', models.CharField(blank=True, max_length=200, verbose_name='Name (Arabic)')), + ('display_name', models.CharField(blank=True, help_text="Display name (English). Falls back to 'name' if empty.", max_length=200)), + ('display_name_ar', models.CharField(blank=True, help_text="Display name (Arabic). Falls back to 'name_ar' if empty.", max_length=200, verbose_name='Display Name (Arabic)')), + ('code', models.CharField(max_length=50, unique=True)), + ('address', models.TextField(blank=True)), + ('city', models.CharField(blank=True, max_length=100)), + ('phone', models.CharField(blank=True, max_length=20)), + ('email', models.EmailField(blank=True, max_length=254)), + ('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='active', max_length=20)), + ('license_number', models.CharField(blank=True, max_length=100)), + ('capacity', models.IntegerField(blank=True, help_text='Bed capacity', null=True)), + ('metadata', models.JSONField(blank=True, default=dict, help_text='Hospital configuration settings')), + ('ceo', models.ForeignKey(blank=True, help_text='Chief Executive Officer', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hospitals_as_ceo', to=settings.AUTH_USER_MODEL, verbose_name='CEO')), + ('cfo', models.ForeignKey(blank=True, help_text='Chief Financial Officer', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hospitals_as_cfo', to=settings.AUTH_USER_MODEL, verbose_name='CFO')), + ('coo', models.ForeignKey(blank=True, help_text='Chief Operating Officer', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hospitals_as_coo', to=settings.AUTH_USER_MODEL, verbose_name='COO')), + ('medical_director', models.ForeignKey(blank=True, help_text='Medical Director', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hospitals_as_medical_director', to=settings.AUTH_USER_MODEL, verbose_name='Medical Director')), + ('organization', models.ForeignKey(blank=True, help_text='Parent organization (null for backward compatibility)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='hospitals', to='organizations.organization')), + ], + options={ + 'verbose_name_plural': 'Hospitals', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Department', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200)), + ('name_en', models.CharField(blank=True, max_length=200)), + ('name_ar', models.CharField(blank=True, max_length=200, verbose_name='Name (Arabic)')), + ('code', models.CharField(db_index=True, max_length=50)), + ('category', models.CharField(blank=True, choices=[('nursing', 'Nursing'), ('support_services', 'Support Services'), ('medical', 'Medical'), ('non_medical', 'Non-Medical')], db_index=True, default='', max_length=30)), + ('phone', models.CharField(blank=True, max_length=20)), + ('email', models.EmailField(blank=True, max_length=254)), + ('location', models.CharField(blank=True, help_text='Building/Floor/Room', max_length=200)), + ('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='active', max_length=20)), + ('manager', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='managed_departments', to=settings.AUTH_USER_MODEL)), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sub_departments', to='organizations.department')), + ('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='departments', to='organizations.hospital')), + ], + options={ + 'ordering': ['hospital', 'name'], + }, + ), + migrations.CreateModel( + name='Patient', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('mrn', models.CharField(max_length=50, unique=True, verbose_name='Medical Record Number')), + ('national_id', apps.core.encryption.EncryptedCharField(blank=True, default='', max_length=256)), + ('national_id_hash', models.CharField(blank=True, db_index=True, default='', max_length=64)), + ('first_name', models.CharField(max_length=100)), + ('last_name', models.CharField(max_length=100)), + ('first_name_ar', models.CharField(blank=True, max_length=100)), + ('last_name_ar', models.CharField(blank=True, max_length=100)), + ('date_of_birth', models.DateField(blank=True, null=True)), + ('gender', models.CharField(blank=True, choices=[('male', 'Male'), ('female', 'Female'), ('other', 'Other')], max_length=10)), + ('nationality', models.CharField(blank=True, db_index=True, max_length=100)), + ('phone', models.CharField(blank=True, max_length=20)), + ('email', models.EmailField(blank=True, max_length=254)), + ('address', models.TextField(blank=True)), + ('city', models.CharField(blank=True, max_length=100)), + ('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='active', max_length=20)), + ('primary_hospital', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='patients', to='organizations.hospital')), + ], + options={ + 'ordering': ['last_name', 'first_name'], + }, + ), + migrations.CreateModel( + name='Staff', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('first_name', models.CharField(max_length=100)), + ('last_name', models.CharField(max_length=100)), + ('first_name_ar', models.CharField(blank=True, max_length=100)), + ('last_name_ar', models.CharField(blank=True, max_length=100)), + ('staff_type', models.CharField(choices=[('physician', 'Physician'), ('nurse', 'Nurse'), ('admin', 'Administrative'), ('other', 'Other')], max_length=20)), + ('department_type', models.CharField(blank=True, choices=[('nursing', 'Nursing'), ('support_services', 'Support Services'), ('medical', 'Medical'), ('non_medical', 'Non-Medical')], db_index=True, default='', max_length=30)), + ('job_title', models.CharField(max_length=200)), + ('license_number', models.CharField(blank=True, max_length=100, null=True, unique=True)), + ('specialization', models.CharField(blank=True, max_length=200)), + ('email', models.EmailField(blank=True, max_length=254)), + ('phone', models.CharField(blank=True, max_length=20, verbose_name='Phone Number')), + ('employee_id', models.CharField(max_length=50, unique=True)), + ('name', models.CharField(blank=True, max_length=300, verbose_name='Full Name (Original)')), + ('name_ar', models.CharField(blank=True, max_length=300, verbose_name='Full Name (Arabic)')), + ('civil_id', models.CharField(blank=True, db_index=True, max_length=50, verbose_name='Civil Identity Number')), + ('country', models.CharField(blank=True, max_length=100, verbose_name='Country')), + ('country_ar', models.CharField(blank=True, max_length=100, verbose_name='Country (Arabic)')), + ('location', models.CharField(blank=True, max_length=200, verbose_name='Location')), + ('location_ar', models.CharField(blank=True, max_length=200, verbose_name='Location (Arabic)')), + ('gender', models.CharField(blank=True, choices=[('male', 'Male'), ('female', 'Female'), ('other', 'Other')], max_length=10)), + ('department_name', models.CharField(blank=True, max_length=200, verbose_name='Department (Original)')), + ('department_name_ar', models.CharField(blank=True, max_length=200, verbose_name='Department (Arabic)')), + ('section', models.CharField(blank=True, max_length=200, verbose_name='Section')), + ('section_ar', models.CharField(blank=True, max_length=200, verbose_name='Section (Arabic)')), + ('subsection', models.CharField(blank=True, max_length=200, verbose_name='Subsection')), + ('subsection_ar', models.CharField(blank=True, max_length=200, verbose_name='Subsection (Arabic)')), + ('job_title_ar', models.CharField(blank=True, max_length=200, verbose_name='Job Title (Arabic)')), + ('is_head', models.BooleanField(default=False, verbose_name='Is Head')), + ('physician', models.BooleanField(default=False, help_text='Set to True when staff record comes from physician rating import', verbose_name='Is Physician')), + ('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='active', max_length=20)), + ('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='staff', to='organizations.department')), + ('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='staff', to='organizations.hospital')), + ('report_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='direct_reports', to='organizations.staff', verbose_name='Reports To')), + ('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='staff_profile', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='department', + name='respondent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='respondent_departments', to='organizations.staff'), + ), + migrations.CreateModel( + name='StaffSection', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200)), + ('name_ar', models.CharField(blank=True, max_length=200, verbose_name='Name (Arabic)')), + ('code', models.CharField(blank=True, max_length=50)), + ('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='active', max_length=20)), + ('department', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sections', to='organizations.department')), + ('head', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='headed_sections', to='organizations.staff')), + ], + options={ + 'ordering': ['department', 'name'], + 'unique_together': {('department', 'name')}, + }, + ), + migrations.AddField( + model_name='staff', + name='section_fk', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='staff_members', to='organizations.staffsection', verbose_name='Section (FK)'), + ), + migrations.CreateModel( + name='StaffSubsection', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200)), + ('name_ar', models.CharField(blank=True, max_length=200, verbose_name='Name (Arabic)')), + ('code', models.CharField(blank=True, max_length=50)), + ('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='active', max_length=20)), + ('head', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='headed_subsections', to='organizations.staff')), + ('section', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subsections', to='organizations.staffsection')), + ], + options={ + 'ordering': ['section', 'name'], + 'unique_together': {('section', 'name')}, + }, + ), + migrations.AddField( + model_name='staff', + name='subsection_fk', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='staff_members', to='organizations.staffsubsection', verbose_name='Subsection (FK)'), + ), + migrations.CreateModel( + name='SubSection', + fields=[ + ('internal_id', models.IntegerField(primary_key=True, serialize=False)), + ('name_ar', models.CharField(max_length=255)), + ('name_en', models.CharField(max_length=255)), + ('location', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subsections', to='organizations.location')), + ('main_section', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subsections', to='organizations.mainsection')), + ], + ), + migrations.AlterUniqueTogether( + name='department', + unique_together={('hospital', 'code')}, + ), + migrations.AddIndex( + model_name='staff', + index=models.Index(fields=['hospital', 'department', 'status'], name='organizatio_hospita_b3a37a_idx'), + ), + migrations.AddIndex( + model_name='staff', + index=models.Index(fields=['hospital', 'status'], name='organizatio_hospita_136205_idx'), + ), + migrations.AddIndex( + model_name='staff', + index=models.Index(fields=['department', 'status'], name='organizatio_departm_7a09d9_idx'), + ), + migrations.AddIndex( + model_name='staff', + index=models.Index(fields=['status'], name='organizatio_status_2156dc_idx'), + ), + ] diff --git a/apps/physicians/migrations/0001_initial.py b/apps/physicians/migrations/0001_initial.py new file mode 100644 index 0000000..988ac0a --- /dev/null +++ b/apps/physicians/migrations/0001_initial.py @@ -0,0 +1,110 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('organizations', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='DoctorRatingImportJob', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed'), ('partial', 'Partial Success')], default='pending', max_length=20)), + ('source', models.CharField(choices=[('his_api', 'HIS API'), ('csv_upload', 'CSV Upload')], max_length=20)), + ('total_records', models.IntegerField(default=0)), + ('processed_count', models.IntegerField(default=0)), + ('success_count', models.IntegerField(default=0)), + ('failed_count', models.IntegerField(default=0)), + ('skipped_count', models.IntegerField(default=0)), + ('started_at', models.DateTimeField(blank=True, null=True)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ('results', models.JSONField(blank=True, default=dict, help_text='Processing results and errors')), + ('error_message', models.TextField(blank=True)), + ('raw_data', models.JSONField(blank=True, default=list, help_text='Stored raw data for processing')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='doctor_rating_jobs', to=settings.AUTH_USER_MODEL)), + ('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='doctor_rating_jobs', to='organizations.hospital')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='PhysicianIndividualRating', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('source', models.CharField(choices=[('his_api', 'HIS API'), ('csv_import', 'CSV Import'), ('manual', 'Manual Entry')], default='manual', max_length=20)), + ('source_reference', models.CharField(blank=True, help_text='Reference ID from source system (e.g., HIS record ID)', max_length=100)), + ('doctor_name_raw', models.CharField(help_text='Doctor name as received (may include ID prefix)', max_length=300)), + ('doctor_id', models.CharField(blank=True, db_index=True, help_text="Doctor ID extracted from source (e.g., '10738')", max_length=50)), + ('doctor_name', models.CharField(blank=True, help_text='Clean doctor name without ID', max_length=200)), + ('department_name', models.CharField(blank=True, help_text='Department name from source', max_length=200)), + ('patient_uhid', models.CharField(blank=True, db_index=True, help_text='Patient UHID/MRN (optional for HIS ratings)', max_length=100, null=True)), + ('patient_name', models.CharField(blank=True, max_length=300, null=True)), + ('patient_gender', models.CharField(blank=True, default='', max_length=20)), + ('patient_age', models.CharField(blank=True, default='', max_length=50)), + ('patient_nationality', models.CharField(blank=True, default='', max_length=100)), + ('patient_phone', models.CharField(blank=True, default='', max_length=30)), + ('patient_type', models.CharField(blank=True, choices=[('IP', 'Inpatient'), ('OP', 'Outpatient'), ('ER', 'Emergency'), ('DC', 'Day Case')], max_length=10, null=True)), + ('admit_date', models.DateTimeField(blank=True, null=True)), + ('discharge_date', models.DateTimeField(blank=True, null=True)), + ('rating', models.IntegerField(help_text='Rating from 1-5')), + ('feedback', models.TextField(blank=True)), + ('rating_date', models.DateTimeField()), + ('is_aggregated', models.BooleanField(default=False, help_text='Whether this rating has been included in monthly aggregation')), + ('aggregated_at', models.DateTimeField(blank=True, null=True)), + ('metadata', models.JSONField(blank=True, default=dict, help_text='Additional data from source')), + ('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='physician_ratings', to='organizations.hospital')), + ('staff', models.ForeignKey(blank=True, help_text='Linked staff record (if matched)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='individual_ratings', to='organizations.staff')), + ], + options={ + 'ordering': ['-rating_date', '-created_at'], + 'indexes': [models.Index(fields=['hospital', '-rating_date'], name='physicians__hospita_aeea63_idx'), models.Index(fields=['staff', '-rating_date'], name='physicians__staff_i_6b8c39_idx'), models.Index(fields=['doctor_id', '-rating_date'], name='physicians__doctor__b60e4e_idx'), models.Index(fields=['is_aggregated', 'rating_date'], name='physicians__is_aggr_01d659_idx'), models.Index(fields=['patient_uhid', '-rating_date'], name='physicians__patient_5e8644_idx')], + }, + ), + migrations.CreateModel( + name='PhysicianMonthlyRating', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('year', models.IntegerField(db_index=True)), + ('month', models.IntegerField(db_index=True, help_text='1-12')), + ('average_rating', models.DecimalField(decimal_places=2, help_text='Average rating (1-5)', max_digits=3)), + ('total_surveys', models.IntegerField(help_text='Number of surveys included')), + ('positive_count', models.IntegerField(default=0)), + ('neutral_count', models.IntegerField(default=0)), + ('negative_count', models.IntegerField(default=0)), + ('rating_1_count', models.IntegerField(default=0)), + ('rating_2_count', models.IntegerField(default=0)), + ('rating_3_count', models.IntegerField(default=0)), + ('rating_4_count', models.IntegerField(default=0)), + ('rating_5_count', models.IntegerField(default=0)), + ('md_consult_rating', models.DecimalField(blank=True, decimal_places=2, max_digits=3, null=True)), + ('hospital_rank', models.IntegerField(blank=True, help_text='Rank within hospital', null=True)), + ('department_rank', models.IntegerField(blank=True, help_text='Rank within department', null=True)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('staff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='monthly_ratings', to='organizations.staff')), + ], + options={ + 'ordering': ['-year', '-month', '-average_rating'], + 'indexes': [models.Index(fields=['staff', '-year', '-month'], name='physicians__staff_i_f4cc8b_idx'), models.Index(fields=['year', 'month', '-average_rating'], name='physicians__year_e38883_idx')], + 'unique_together': {('staff', 'year', 'month')}, + }, + ), + ] diff --git a/apps/presentations/migrations/0001_initial.py b/apps/presentations/migrations/0001_initial.py new file mode 100644 index 0000000..0aef49b --- /dev/null +++ b/apps/presentations/migrations/0001_initial.py @@ -0,0 +1,134 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('organizations', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Presentation', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('title', models.CharField(max_length=300)), + ('subtitle', models.CharField(blank=True, max_length=500)), + ('description', models.TextField(blank=True)), + ('theme', models.CharField(choices=[('healthcare_modern', 'Healthcare Modern'), ('corporate_navy', 'Corporate Navy'), ('dark_command', 'Dark Command Center')], default='healthcare_modern', max_length=30)), + ('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published'), ('archived', 'Archived')], default='draft', max_length=20)), + ('presentation_type', models.CharField(blank=True, help_text='Type of report (e.g., quarterly, monthly, custom)', max_length=50)), + ('thumbnail', models.ImageField(blank=True, null=True, upload_to='presentations/thumbnails/')), + ('presentation_date', models.DateField(blank=True, null=True)), + ('is_shared', models.BooleanField(default=False)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='presentations', to=settings.AUTH_USER_MODEL)), + ('hospital', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='presentations', to='organizations.hospital')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ReportTemplate', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200)), + ('slug', models.SlugField(max_length=220, unique=True)), + ('description', models.TextField(blank=True)), + ('data_source', models.CharField(help_text='Key in REPORT_DATA_SOURCES registry', max_length=50)), + ('reference_pdf', models.FileField(blank=True, null=True, upload_to='report_templates/')), + ('parsed_structure', models.JSONField(blank=True, default=dict, help_text='Raw AI analysis of the reference PDF')), + ('style_config', models.JSONField(blank=True, default=dict, help_text='Theme colors, row colors, fonts')), + ('ai_prompt_template', models.TextField(blank=True, help_text='Prompt template for AI insight generation. Use {{ data_summary }} placeholder.')), + ('active', models.BooleanField(default=True)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='report_templates', to=settings.AUTH_USER_MODEL)), + ('hospital', models.ForeignKey(blank=True, help_text='Null = available for all hospitals', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='report_templates', to='organizations.hospital')), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='ReportTemplateSlide', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('order', models.PositiveIntegerField(default=0)), + ('layout', models.CharField(choices=[('cover', 'Cover'), ('section_divider', 'Section Divider'), ('kpi_dashboard', 'KPI Dashboard'), ('full_chart', 'Full Chart'), ('chart_metrics', 'Chart + Metrics'), ('data_table', 'Data Table'), ('two_column', 'Two Column'), ('quote', 'Quote / Callout'), ('timeline', 'Timeline'), ('comparison', 'Comparison'), ('team_grid', 'Team / Department Grid'), ('closing', 'Closing')], default='cover', max_length=30)), + ('section_label', models.CharField(blank=True, help_text='For section dividers: e.g. "01", "02"', max_length=50)), + ('title_template', models.CharField(blank=True, help_text='Supports {{ variable }} substitution', max_length=300)), + ('subtitle_template', models.CharField(blank=True, help_text='Supports {{ variable }} substitution', max_length=500)), + ('content_mapping', models.JSONField(blank=True, default=dict, help_text='How data maps to slide content. Structure depends on layout type.')), + ('repeat_source', models.CharField(blank=True, help_text='Data key to repeat over, e.g. "by_department". Creates one slide per item.', max_length=100)), + ('repeat_title_key', models.CharField(blank=True, default='name', help_text='Key in repeat item for slide title, e.g. "department_name"', max_length=100)), + ('repeat_subtitle_template', models.CharField(blank=True, help_text='Subtitle template for repeated slides. {{ item.X }} available.', max_length=300)), + ('max_rows', models.PositiveIntegerField(default=18, help_text='Max data rows per slide (tables split across slides)')), + ('style_overrides', models.JSONField(blank=True, default=dict, help_text='Per-slide style overrides (row colors, etc.)')), + ('speaker_notes_template', models.TextField(blank=True, help_text='Speaker notes template with {{ variable }} support')), + ('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='template_slides', to='presentations.reporttemplate')), + ], + options={ + 'ordering': ['order'], + }, + ), + migrations.CreateModel( + name='Slide', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('layout', models.CharField(choices=[('cover', 'Cover'), ('section_divider', 'Section Divider'), ('kpi_dashboard', 'KPI Dashboard'), ('full_chart', 'Full Chart'), ('chart_metrics', 'Chart + Metrics'), ('data_table', 'Data Table'), ('two_column', 'Two Column'), ('quote', 'Quote / Callout'), ('timeline', 'Timeline'), ('comparison', 'Comparison'), ('team_grid', 'Team / Department Grid'), ('closing', 'Closing')], default='cover', max_length=30)), + ('order', models.PositiveIntegerField(default=0)), + ('title', models.CharField(blank=True, max_length=300)), + ('subtitle', models.CharField(blank=True, max_length=500)), + ('content', models.JSONField(blank=True, default=dict, help_text='Layout-specific content. Structure varies by slide type: kpi_dashboard={metrics:[...]}, full_chart={chart_config:{...}}, data_table={headers:[...], rows:[...]}, etc.')), + ('background_color', models.CharField(blank=True, max_length=20)), + ('speaker_notes', models.TextField(blank=True)), + ('presentation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='slides', to='presentations.presentation')), + ], + options={ + 'ordering': ['order'], + }, + ), + migrations.AddIndex( + model_name='presentation', + index=models.Index(fields=['status', '-created_at'], name='presentatio_status_796dc2_idx'), + ), + migrations.AddIndex( + model_name='presentation', + index=models.Index(fields=['hospital', '-created_at'], name='presentatio_hospita_440c35_idx'), + ), + migrations.AddIndex( + model_name='presentation', + index=models.Index(fields=['created_by', '-created_at'], name='presentatio_created_8f92d8_idx'), + ), + migrations.AddIndex( + model_name='reporttemplate', + index=models.Index(fields=['active', '-created_at'], name='presentatio_active_9f6871_idx'), + ), + migrations.AddIndex( + model_name='reporttemplate', + index=models.Index(fields=['data_source'], name='presentatio_data_so_3f9155_idx'), + ), + migrations.AddIndex( + model_name='reporttemplateslide', + index=models.Index(fields=['template', 'order'], name='presentatio_templat_377148_idx'), + ), + migrations.AddIndex( + model_name='slide', + index=models.Index(fields=['presentation', 'order'], name='presentatio_present_0069fe_idx'), + ), + ] diff --git a/apps/projects/migrations/0001_initial.py b/apps/projects/migrations/0001_initial.py new file mode 100644 index 0000000..718305f --- /dev/null +++ b/apps/projects/migrations/0001_initial.py @@ -0,0 +1,105 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('organizations', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='QIProjectTask', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('title', models.CharField(max_length=500)), + ('description', models.TextField(blank=True)), + ('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='pending', max_length=20)), + ('due_date', models.DateField(blank=True, null=True)), + ('completed_date', models.DateField(blank=True, null=True)), + ('order', models.IntegerField(default=0)), + ], + options={ + 'ordering': ['project', 'order'], + }, + ), + migrations.CreateModel( + name='FOCUSPhase', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('phase', models.CharField(choices=[('find', 'Find'), ('organize', 'Organize'), ('clarify', 'Clarify'), ('understand', 'Understand'), ('select', 'Select')], max_length=15)), + ('title', models.CharField(blank=True, max_length=300)), + ('description', models.TextField(blank=True)), + ('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='pending', max_length=20)), + ('start_date', models.DateField(blank=True, null=True)), + ('due_date', models.DateField(blank=True, null=True)), + ('completed_date', models.DateField(blank=True, null=True)), + ('findings', models.TextField(blank=True)), + ('order', models.IntegerField(default=0)), + ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_focus_phases', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['project', 'order'], + }, + ), + migrations.CreateModel( + name='PDCAPhase', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('phase', models.CharField(choices=[('plan', 'Plan'), ('do', 'Do'), ('check', 'Check'), ('act', 'Act')], max_length=10)), + ('title', models.CharField(blank=True, max_length=300)), + ('description', models.TextField(blank=True)), + ('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='pending', max_length=20)), + ('start_date', models.DateField(blank=True, null=True)), + ('due_date', models.DateField(blank=True, null=True)), + ('completed_date', models.DateField(blank=True, null=True)), + ('findings', models.TextField(blank=True)), + ('order', models.IntegerField(default=0)), + ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_pdca_phases', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['project', 'order'], + }, + ), + migrations.CreateModel( + name='QIProject', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200)), + ('name_ar', models.CharField(blank=True, max_length=200)), + ('description', models.TextField()), + ('is_template', models.BooleanField(db_index=True, default=False, help_text='If True, this is a reusable template, not an active project')), + ('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='pending', max_length=20)), + ('start_date', models.DateField(blank=True, null=True)), + ('target_completion_date', models.DateField(blank=True, db_index=True, null=True)), + ('actual_completion_date', models.DateField(blank=True, null=True)), + ('outcome_description', models.TextField(blank=True)), + ('success_metrics', models.JSONField(blank=True, default=dict, help_text='Success metrics and results')), + ('focus_enabled', models.BooleanField(default=True, help_text='Whether to use FOCUS methodology alongside PDCA')), + ('metadata', models.JSONField(blank=True, default=dict)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_qi_projects', to=settings.AUTH_USER_MODEL)), + ('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='qi_projects', to='organizations.department')), + ('hospital', models.ForeignKey(blank=True, help_text='Null for global templates available to all hospitals', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='qi_projects', to='organizations.hospital')), + ('project_lead', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='led_projects', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/apps/projects/migrations/0002_initial.py b/apps/projects/migrations/0002_initial.py new file mode 100644 index 0000000..cc9dc9f --- /dev/null +++ b/apps/projects/migrations/0002_initial.py @@ -0,0 +1,83 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('projects', '0001_initial'), + ('px_action_center', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='qiproject', + name='related_actions', + field=models.ManyToManyField(blank=True, related_name='qi_projects', to='px_action_center.pxaction'), + ), + migrations.AddField( + model_name='qiproject', + name='team_members', + field=models.ManyToManyField(blank=True, related_name='qi_projects', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='pdcaphase', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pdca_phases', to='projects.qiproject'), + ), + migrations.AddField( + model_name='focusphase', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='focus_phases', to='projects.qiproject'), + ), + migrations.AddField( + model_name='qiprojecttask', + name='assigned_to', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='qi_tasks', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='qiprojecttask', + name='focus_phase', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='projects.focusphase'), + ), + migrations.AddField( + model_name='qiprojecttask', + name='pdca_phase', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='projects.pdcaphase'), + ), + migrations.AddField( + model_name='qiprojecttask', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='projects.qiproject'), + ), + migrations.AddIndex( + model_name='qiproject', + index=models.Index(fields=['hospital', 'status', '-created_at'], name='projects_qi_hospita_e5dfc7_idx'), + ), + migrations.AddIndex( + model_name='qiproject', + index=models.Index(fields=['is_template', 'hospital'], name='projects_qi_is_temp_df9bbd_idx'), + ), + migrations.AddIndex( + model_name='qiproject', + index=models.Index(fields=['project_lead', 'status'], name='projects_qi_project_3691bd_idx'), + ), + migrations.AddIndex( + model_name='qiproject', + index=models.Index(fields=['status', 'target_completion_date'], name='projects_qi_status_635dda_idx'), + ), + migrations.AlterUniqueTogether( + name='pdcaphase', + unique_together={('project', 'phase')}, + ), + migrations.AlterUniqueTogether( + name='focusphase', + unique_together={('project', 'phase')}, + ), + ] diff --git a/apps/px_action_center/migrations/0001_initial.py b/apps/px_action_center/migrations/0001_initial.py new file mode 100644 index 0000000..50be25c --- /dev/null +++ b/apps/px_action_center/migrations/0001_initial.py @@ -0,0 +1,174 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('organizations', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='PXAction', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('source_type', models.CharField(choices=[('survey', 'Negative Survey'), ('complaint', 'Complaint'), ('complaint_resolution', 'Negative Complaint Resolution'), ('social_media', 'Social Media'), ('call_center', 'Call Center'), ('kpi', 'KPI Decline'), ('manual', 'Manual Entry'), ('patient_family_committee', 'Patient & Family Rights Committee Meeting'), ('executive_committee', 'Executive Committee Meeting'), ('department_meeting', 'Department Meeting'), ('rounds', 'Ward/Department Rounds'), ('staff_feedback', 'Staff Feedback/Comments'), ('patient_observation', 'Patient Observation'), ('quality_audit', 'Quality Audit'), ('management_review', 'Management Review')], db_index=True, max_length=50)), + ('object_id', models.UUIDField(blank=True, null=True)), + ('title', models.CharField(max_length=500)), + ('description', models.TextField()), + ('category', models.CharField(choices=[('clinical_quality', 'Clinical Quality'), ('patient_safety', 'Patient Safety'), ('service_quality', 'Service Quality'), ('staff_behavior', 'Staff Behavior'), ('facility', 'Facility & Environment'), ('process_improvement', 'Process Improvement'), ('other', 'Other')], db_index=True, max_length=100)), + ('priority', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], db_index=True, default='medium', max_length=20)), + ('severity', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], db_index=True, default='medium', max_length=20)), + ('status', models.CharField(choices=[('open', 'Open'), ('in_progress', 'In Progress'), ('pending_approval', 'Pending Approval'), ('approved', 'Approved'), ('closed', 'Closed'), ('cancelled', 'Cancelled')], db_index=True, default='open', max_length=20)), + ('assigned_at', models.DateTimeField(blank=True, null=True)), + ('due_at', models.DateTimeField(db_index=True, help_text='SLA deadline')), + ('is_overdue', models.BooleanField(db_index=True, default=False)), + ('reminder_sent_at', models.DateTimeField(blank=True, null=True)), + ('escalated_at', models.DateTimeField(blank=True, null=True)), + ('escalation_level', models.IntegerField(default=0, help_text='Number of times escalated')), + ('requires_approval', models.BooleanField(default=True, help_text='Requires PX Admin approval before closure')), + ('approved_at', models.DateTimeField(blank=True, null=True)), + ('rejected_at', models.DateTimeField(blank=True, null=True)), + ('rejection_reason', models.TextField(blank=True)), + ('closed_at', models.DateTimeField(blank=True, null=True)), + ('action_plan', models.TextField(blank=True)), + ('outcome', models.TextField(blank=True)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('approved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approved_actions', to=settings.AUTH_USER_MODEL)), + ('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_actions', to=settings.AUTH_USER_MODEL)), + ('closed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='closed_actions', to=settings.AUTH_USER_MODEL)), + ('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='px_actions', to='organizations.department')), + ('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='px_actions', to='organizations.hospital')), + ('rejected_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='rejected_actions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='PXActionAttachment', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('file', models.FileField(upload_to='actions/%Y/%m/%d/')), + ('filename', models.CharField(max_length=500)), + ('file_type', models.CharField(blank=True, max_length=100)), + ('file_size', models.IntegerField(help_text='File size in bytes')), + ('description', models.TextField(blank=True)), + ('is_evidence', models.BooleanField(default=False, help_text='Mark as evidence for closure')), + ('action', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='px_action_center.pxaction')), + ('uploaded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='action_attachments', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='PXActionLog', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('log_type', models.CharField(choices=[('status_change', 'Status Change'), ('assignment', 'Assignment'), ('escalation', 'Escalation'), ('note', 'Note'), ('evidence', 'Evidence Added'), ('approval', 'Approval'), ('sla_reminder', 'SLA Reminder')], db_index=True, max_length=50)), + ('message', models.TextField()), + ('old_status', models.CharField(blank=True, max_length=20)), + ('new_status', models.CharField(blank=True, max_length=20)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('action', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='px_action_center.pxaction')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='action_logs', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='PXActionSLAConfig', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200)), + ('critical_hours', models.IntegerField(default=24)), + ('high_hours', models.IntegerField(default=48)), + ('medium_hours', models.IntegerField(default=72)), + ('low_hours', models.IntegerField(default=120)), + ('reminder_hours_before', models.IntegerField(default=4, help_text='Send reminder X hours before due')), + ('auto_escalate', models.BooleanField(default=True, help_text='Automatically escalate when overdue')), + ('escalation_delay_hours', models.IntegerField(default=2, help_text='Hours after overdue before escalation')), + ('is_active', models.BooleanField(default=True)), + ('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='action_sla_configs', to='organizations.department')), + ('hospital', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='action_sla_configs', to='organizations.hospital')), + ], + options={ + 'ordering': ['hospital', 'name'], + }, + ), + migrations.CreateModel( + name='RoutingRule', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200)), + ('description', models.TextField(blank=True)), + ('source_type', models.CharField(blank=True, choices=[('survey', 'Negative Survey'), ('complaint', 'Complaint'), ('complaint_resolution', 'Negative Complaint Resolution'), ('social_media', 'Social Media'), ('call_center', 'Call Center'), ('kpi', 'KPI Decline'), ('manual', 'Manual Entry'), ('patient_family_committee', 'Patient & Family Rights Committee Meeting'), ('executive_committee', 'Executive Committee Meeting'), ('department_meeting', 'Department Meeting'), ('rounds', 'Ward/Department Rounds'), ('staff_feedback', 'Staff Feedback/Comments'), ('patient_observation', 'Patient Observation'), ('quality_audit', 'Quality Audit'), ('management_review', 'Management Review')], max_length=50)), + ('category', models.CharField(blank=True, max_length=100)), + ('severity', models.CharField(blank=True, choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], max_length=20)), + ('assign_to_role', models.CharField(blank=True, help_text="Role to assign to (e.g., 'PX Employee')", max_length=50)), + ('priority', models.IntegerField(default=0, help_text='Higher priority rules are evaluated first')), + ('is_active', models.BooleanField(default=True)), + ('assign_to_department', models.ForeignKey(blank=True, help_text='Department to assign to', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='routing_target_rules', to='organizations.department')), + ('assign_to_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='routing_rules', to=settings.AUTH_USER_MODEL)), + ('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='routing_rules', to='organizations.department')), + ('hospital', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='routing_rules', to='organizations.hospital')), + ], + options={ + 'ordering': ['-priority', 'name'], + }, + ), + migrations.AddIndex( + model_name='pxaction', + index=models.Index(fields=['status', '-created_at'], name='px_action_c_status_3bd857_idx'), + ), + migrations.AddIndex( + model_name='pxaction', + index=models.Index(fields=['hospital', 'status', '-created_at'], name='px_action_c_hospita_6b2d44_idx'), + ), + migrations.AddIndex( + model_name='pxaction', + index=models.Index(fields=['assigned_to', 'status', '-created_at'], name='px_action_c_assigne_c67d35_idx'), + ), + migrations.AddIndex( + model_name='pxaction', + index=models.Index(fields=['department', 'status', '-created_at'], name='px_action_c_departm_9ed6b7_idx'), + ), + migrations.AddIndex( + model_name='pxaction', + index=models.Index(fields=['is_overdue', 'status'], name='px_action_c_is_over_e0d12c_idx'), + ), + migrations.AddIndex( + model_name='pxaction', + index=models.Index(fields=['due_at', 'status'], name='px_action_c_due_at_947f38_idx'), + ), + migrations.AddIndex( + model_name='pxaction', + index=models.Index(fields=['source_type', '-created_at'], name='px_action_c_source__3f0ae5_idx'), + ), + migrations.AddIndex( + model_name='pxactionlog', + index=models.Index(fields=['action', '-created_at'], name='px_action_c_action__656e57_idx'), + ), + ] diff --git a/apps/px_sources/migrations/0001_initial.py b/apps/px_sources/migrations/0001_initial.py new file mode 100644 index 0000000..8d296f8 --- /dev/null +++ b/apps/px_sources/migrations/0001_initial.py @@ -0,0 +1,152 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('complaints', '0002_initial'), + ('contenttypes', '0002_remove_content_type_name'), + ('organizations', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='PXSource', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('code', models.CharField(blank=True, default='', help_text='Unique code for API references', max_length=50, unique=True)), + ('name_en', models.CharField(help_text='Source name in English', max_length=200)), + ('name_ar', models.CharField(blank=True, help_text='Source name in Arabic', max_length=200)), + ('description', models.TextField(blank=True, help_text='Detailed description')), + ('source_type', models.CharField(choices=[('internal', 'Internal'), ('external', 'External'), ('partner', 'Partner'), ('government', 'Government'), ('other', 'Other')], db_index=True, default='internal', help_text='Type of source', max_length=50)), + ('contact_email', models.EmailField(blank=True, help_text='Contact email for external sources', max_length=254)), + ('contact_phone', models.CharField(blank=True, help_text='Contact phone for external sources', max_length=20)), + ('is_active', models.BooleanField(db_index=True, default=True, help_text='Whether this source is active for selection')), + ('metadata', models.JSONField(blank=True, default=dict, help_text='Additional metadata')), + ('total_complaints', models.IntegerField(default=0, editable=False, help_text='Cached total complaints count')), + ('total_inquiries', models.IntegerField(default=0, editable=False, help_text='Cached total inquiries count')), + ], + options={ + 'verbose_name': 'PX Source', + 'verbose_name_plural': 'PX Sources', + 'ordering': ['name_en'], + 'indexes': [models.Index(fields=['is_active', 'name_en'], name='px_sources__is_acti_ea1b54_idx')], + }, + ), + migrations.CreateModel( + name='SourceUser', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('is_active', models.BooleanField(db_index=True, default=True, help_text='Whether this source user is active')), + ('can_create_complaints', models.BooleanField(default=True, help_text='User can create complaints from this source')), + ('can_create_inquiries', models.BooleanField(default=True, help_text='User can create inquiries from this source')), + ('can_create_observations', models.BooleanField(default=True, help_text='User can create observations from this source')), + ('can_create_suggestions', models.BooleanField(default=True, help_text='User can create suggestions from this source')), + ('hospital', models.ForeignKey(blank=True, help_text='Hospital this source user belongs to', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='source_users', to='organizations.hospital')), + ('source', models.ForeignKey(help_text='Source managed by this user', on_delete=django.db.models.deletion.CASCADE, related_name='source_users', to='px_sources.pxsource')), + ('user', models.OneToOneField(help_text='User who manages this source', on_delete=django.db.models.deletion.CASCADE, related_name='source_user_profile', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Source User', + 'verbose_name_plural': 'Source Users', + 'ordering': ['source__name_en'], + }, + ), + migrations.CreateModel( + name='CommunicationRequest', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('patient_name', models.CharField(blank=True, max_length=200)), + ('patient_phone', models.CharField(blank=True, max_length=20)), + ('patient_mrn', models.CharField(blank=True, max_length=50, verbose_name='Patient MRN')), + ('reason', models.CharField(choices=[('complaint_followup', 'Complaint Follow-up'), ('general_inquiry', 'General Inquiry'), ('feedback', 'Feedback Sharing'), ('urgent', 'Urgent Matter'), ('other', 'Other')], default='general_inquiry', max_length=30)), + ('message', models.TextField(help_text='Message from the patient/source user to PX team')), + ('status', models.CharField(choices=[('pending', 'Pending'), ('contacted', 'Contacted'), ('resolved', 'Resolved'), ('closed', 'Closed')], db_index=True, default='pending', max_length=20)), + ('contacted_at', models.DateTimeField(blank=True, help_text='When PX team contacted the patient', null=True)), + ('resolution_notes', models.TextField(blank=True, help_text='Notes from PX team about the resolution')), + ('resolved_as_object_id', models.UUIDField(blank=True, null=True)), + ('contacted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contacted_communication_requests', to=settings.AUTH_USER_MODEL)), + ('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='communication_requests', to='organizations.hospital')), + ('resolved_as_content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='resolved_communication_requests', to='contenttypes.contenttype')), + ('source_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='communication_requests', to='px_sources.sourceuser')), + ], + options={ + 'verbose_name': 'Communication Request', + 'verbose_name_plural': 'Communication Requests', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='SourceComplaint', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('reference_number', models.CharField(db_index=True, max_length=50, unique=True)), + ('subject', models.CharField(max_length=300)), + ('description', models.TextField()), + ('patient_name', models.CharField(blank=True, max_length=200)), + ('contact_phone', models.CharField(blank=True, max_length=20)), + ('contact_email', models.EmailField(blank=True, max_length=254)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('status', models.CharField(choices=[('open', 'Open'), ('converted', 'Converted'), ('closed', 'Closed')], db_index=True, default='open', max_length=20)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_source_complaints', to=settings.AUTH_USER_MODEL)), + ('px_source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='source_complaints', to='px_sources.pxsource')), + ('system_complaint', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_complaint', to='complaints.complaint')), + ], + options={ + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['px_source', '-created_at'], name='px_sources__px_sour_6a780f_idx'), models.Index(fields=['status'], name='px_sources__status_352ef7_idx')], + }, + ), + migrations.CreateModel( + name='SourceUsage', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('object_id', models.UUIDField(help_text='ID of related object')), + ('content_type', models.ForeignKey(help_text='Type of related object', on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('hospital', models.ForeignKey(blank=True, help_text='Hospital where this source was used', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_usage_records', to='organizations.hospital')), + ('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='usage_records', to='px_sources.pxsource')), + ('user', models.ForeignKey(blank=True, help_text='User who selected this source', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_usage_records', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Source Usage', + 'verbose_name_plural': 'Source Usages', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['source', '-created_at'], name='px_sources__source__13a9ae_idx'), models.Index(fields=['content_type', 'object_id'], name='px_sources__content_30cb33_idx'), models.Index(fields=['hospital', '-created_at'], name='px_sources__hospita_a0479a_idx'), models.Index(fields=['created_at'], name='px_sources__created_8606b0_idx')], + 'unique_together': {('content_type', 'object_id')}, + }, + ), + migrations.AddIndex( + model_name='sourceuser', + index=models.Index(fields=['user', 'is_active'], name='px_sources__user_id_40a726_idx'), + ), + migrations.AddIndex( + model_name='sourceuser', + index=models.Index(fields=['source', 'is_active'], name='px_sources__source__eb51c5_idx'), + ), + migrations.AlterUniqueTogether( + name='sourceuser', + unique_together={('user', 'source')}, + ), + migrations.AddIndex( + model_name='communicationrequest', + index=models.Index(fields=['hospital', 'status', '-created_at'], name='px_sources__hospita_9551ab_idx'), + ), + ] diff --git a/apps/rca/migrations/0001_initial.py b/apps/rca/migrations/0001_initial.py new file mode 100644 index 0000000..f2737c5 --- /dev/null +++ b/apps/rca/migrations/0001_initial.py @@ -0,0 +1,221 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('organizations', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='RootCauseAnalysis', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('object_id', models.UUIDField(blank=True, db_index=True, help_text='ID of the related item', null=True)), + ('title', models.CharField(max_length=500)), + ('description', models.TextField(help_text='Description of the incident/issue')), + ('background', models.TextField(blank=True, help_text='Background information and context')), + ('status', models.CharField(choices=[('draft', 'Draft'), ('in_progress', 'In Progress'), ('review', 'Under Review'), ('approved', 'Approved'), ('closed', 'Closed')], db_index=True, default='draft', max_length=20)), + ('severity', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], db_index=True, default='medium', max_length=20)), + ('priority', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], db_index=True, default='medium', max_length=20)), + ('root_cause_summary', models.TextField(blank=True, help_text='Summary of root cause analysis findings')), + ('assigned_at', models.DateTimeField(blank=True, null=True)), + ('approved_at', models.DateTimeField(blank=True, null=True)), + ('approval_notes', models.TextField(blank=True)), + ('closed_at', models.DateTimeField(blank=True, null=True)), + ('closure_notes', models.TextField(blank=True)), + ('target_completion_date', models.DateField(blank=True, help_text='Target date for RCA completion', null=True)), + ('actual_completion_date', models.DateField(blank=True, null=True)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('is_deleted', models.BooleanField(db_index=True, default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('approved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='rca_approved_items', to=settings.AUTH_USER_MODEL)), + ('assigned_to', models.ForeignKey(blank=True, help_text='Person responsible for RCA', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_rcas', to=settings.AUTH_USER_MODEL)), + ('closed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='closed_rcas', to=settings.AUTH_USER_MODEL)), + ('content_type', models.ForeignKey(blank=True, help_text='Type of the related item', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='related_rcas', to='contenttypes.contenttype')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_rcas', to=settings.AUTH_USER_MODEL)), + ('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_rcas', to=settings.AUTH_USER_MODEL)), + ('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='rcas', to='organizations.department')), + ('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rcas', to='organizations.hospital')), + ], + options={ + 'verbose_name': 'Root Cause Analysis', + 'verbose_name_plural': 'Root Cause Analyses', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='RCAStatusLog', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('old_status', models.CharField(blank=True, max_length=20)), + ('new_status', models.CharField(db_index=True, max_length=20)), + ('notes', models.TextField(blank=True)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('changed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='rca_status_changes', to=settings.AUTH_USER_MODEL)), + ('rca', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='status_logs', to='rca.rootcauseanalysis')), + ], + options={ + 'verbose_name': 'RCA Status Log', + 'verbose_name_plural': 'RCA Status Logs', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='RCARootCause', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('description', models.TextField(help_text='Description of root cause')), + ('category', models.CharField(choices=[('process', 'Process/Procedure'), ('people', 'People/Training'), ('equipment', 'Equipment/Resources'), ('communication', 'Communication'), ('policy', 'Policy/Regulation'), ('environment', 'Environment'), ('technology', 'Technology/Systems'), ('other', 'Other')], db_index=True, max_length=50)), + ('contributing_factors', models.TextField(blank=True, help_text='Factors that contributed to this root cause')), + ('likelihood', models.IntegerField(blank=True, help_text='Likelihood score (1-5)', null=True)), + ('impact', models.IntegerField(blank=True, help_text='Impact score (1-5)', null=True)), + ('risk_score', models.IntegerField(blank=True, help_text='Risk score (likelihood * impact)', null=True)), + ('evidence', models.TextField(blank=True, help_text='Evidence supporting this root cause')), + ('is_verified', models.BooleanField(default=False)), + ('verified_at', models.DateTimeField(blank=True, null=True)), + ('verified_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='verified_root_causes', to=settings.AUTH_USER_MODEL)), + ('rca', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='root_causes', to='rca.rootcauseanalysis')), + ], + options={ + 'verbose_name': 'Root Cause', + 'verbose_name_plural': 'Root Causes', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='RCANote', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('note', models.TextField()), + ('is_internal', models.BooleanField(default=True, help_text='Internal note (not visible in reports)')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='rca_notes', to=settings.AUTH_USER_MODEL)), + ('rca', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='rca.rootcauseanalysis')), + ], + options={ + 'verbose_name': 'RCA Note', + 'verbose_name_plural': 'RCA Notes', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='RCACorrectiveAction', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('description', models.TextField(help_text='Description of corrective action')), + ('action_type', models.CharField(choices=[('preventive', 'Preventive'), ('corrective', 'Corrective'), ('immediate', 'Immediate Action'), ('long_term', 'Long-term Solution')], db_index=True, default='corrective', max_length=20)), + ('target_date', models.DateField(blank=True, help_text='Target date for completion', null=True)), + ('completion_date', models.DateField(blank=True, help_text='Actual completion date', null=True)), + ('status', models.CharField(choices=[('not_started', 'Not Started'), ('in_progress', 'In Progress'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='not_started', max_length=20)), + ('effectiveness_measure', models.TextField(blank=True, help_text='How effectiveness will be measured')), + ('effectiveness_assessment', models.TextField(blank=True, help_text='Assessment of action effectiveness')), + ('effectiveness_score', models.IntegerField(blank=True, help_text='Effectiveness score (1-5)', null=True)), + ('obstacles', models.TextField(blank=True, help_text='Obstacles encountered during implementation')), + ('metadata', models.JSONField(blank=True, default=dict)), + ('responsible_person', models.ForeignKey(blank=True, help_text='Person responsible for implementing the action', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='corrective_actions', to='organizations.staff')), + ('root_cause', models.ForeignKey(blank=True, help_text='Root cause this action addresses', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='corrective_actions', to='rca.rcarootcause')), + ('rca', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='corrective_actions', to='rca.rootcauseanalysis')), + ], + options={ + 'verbose_name': 'Corrective Action', + 'verbose_name_plural': 'Corrective Actions', + 'ordering': ['target_date', '-created_at'], + }, + ), + migrations.CreateModel( + name='RCAAttachment', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('file', models.FileField(upload_to='rca/%Y/%m/%d/')), + ('filename', models.CharField(max_length=500)), + ('file_type', models.CharField(blank=True, max_length=100)), + ('file_size', models.IntegerField(help_text='File size in bytes')), + ('description', models.TextField(blank=True)), + ('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='rca_attachments', to=settings.AUTH_USER_MODEL)), + ('rca', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='rca.rootcauseanalysis')), + ], + options={ + 'verbose_name': 'RCA Attachment', + 'verbose_name_plural': 'RCA Attachments', + 'ordering': ['-created_at'], + }, + ), + migrations.AddIndex( + model_name='rootcauseanalysis', + index=models.Index(fields=['status', '-created_at'], name='rca_rootcau_status_de172e_idx'), + ), + migrations.AddIndex( + model_name='rootcauseanalysis', + index=models.Index(fields=['hospital', 'status', '-created_at'], name='rca_rootcau_hospita_105ba2_idx'), + ), + migrations.AddIndex( + model_name='rootcauseanalysis', + index=models.Index(fields=['department', 'status', '-created_at'], name='rca_rootcau_departm_ca310d_idx'), + ), + migrations.AddIndex( + model_name='rootcauseanalysis', + index=models.Index(fields=['assigned_to', 'status', '-created_at'], name='rca_rootcau_assigne_71e4cf_idx'), + ), + migrations.AddIndex( + model_name='rootcauseanalysis', + index=models.Index(fields=['is_deleted', '-created_at'], name='rca_rootcau_is_dele_1b8ad5_idx'), + ), + migrations.AddIndex( + model_name='rootcauseanalysis', + index=models.Index(fields=['content_type', 'object_id'], name='rca_rootcau_content_08b595_idx'), + ), + migrations.AddIndex( + model_name='rcastatuslog', + index=models.Index(fields=['rca', '-created_at'], name='rca_rcastat_rca_id_26e67b_idx'), + ), + migrations.AddIndex( + model_name='rcastatuslog', + index=models.Index(fields=['new_status', '-created_at'], name='rca_rcastat_new_sta_09a9ea_idx'), + ), + migrations.AddIndex( + model_name='rcarootcause', + index=models.Index(fields=['rca', '-created_at'], name='rca_rcaroot_rca_id_4c7113_idx'), + ), + migrations.AddIndex( + model_name='rcarootcause', + index=models.Index(fields=['category', '-created_at'], name='rca_rcaroot_categor_9eb3ca_idx'), + ), + migrations.AddIndex( + model_name='rcanote', + index=models.Index(fields=['rca', '-created_at'], name='rca_rcanote_rca_id_ade9da_idx'), + ), + migrations.AddIndex( + model_name='rcacorrectiveaction', + index=models.Index(fields=['rca', '-created_at'], name='rca_rcacorr_rca_id_49e717_idx'), + ), + migrations.AddIndex( + model_name='rcacorrectiveaction', + index=models.Index(fields=['status', 'target_date'], name='rca_rcacorr_status_96ba71_idx'), + ), + migrations.AddIndex( + model_name='rcacorrectiveaction', + index=models.Index(fields=['action_type', '-created_at'], name='rca_rcacorr_action__39700e_idx'), + ), + ] diff --git a/apps/references/migrations/0001_initial.py b/apps/references/migrations/0001_initial.py new file mode 100644 index 0000000..b9a1977 --- /dev/null +++ b/apps/references/migrations/0001_initial.py @@ -0,0 +1,123 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import apps.references.models +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('organizations', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ReferenceFolder', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('is_deleted', models.BooleanField(db_index=True, default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('name', models.CharField(db_index=True, max_length=200)), + ('name_ar', models.CharField(blank=True, max_length=200, verbose_name='Name (Arabic)')), + ('description', models.TextField(blank=True)), + ('description_ar', models.TextField(blank=True, verbose_name='Description (Arabic)')), + ('icon', models.CharField(blank=True, help_text="Icon class (e.g., 'fa-folder', 'fa-file-pdf')", max_length=50)), + ('color', models.CharField(blank=True, help_text="Hex color code (e.g., '#007bff')", max_length=7)), + ('order', models.IntegerField(default=0, help_text='Display order within parent folder')), + ('is_active', models.BooleanField(db_index=True, default=True)), + ('access_roles', models.ManyToManyField(blank=True, help_text='Roles that can access this folder (empty = all roles)', related_name='accessible_folders', to='auth.group')), + ('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_%(class)s_set', to=settings.AUTH_USER_MODEL)), + ('hospital', models.ForeignKey(help_text='Tenant hospital for this record', on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_related', to='organizations.hospital')), + ('parent', models.ForeignKey(blank=True, help_text='Parent folder for nested structure', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subfolders', to='references.referencefolder')), + ], + options={ + 'verbose_name': 'Reference Folder', + 'verbose_name_plural': 'Reference Folders', + 'ordering': ['parent__order', 'order', 'name'], + }, + ), + migrations.CreateModel( + name='ReferenceDocument', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('is_deleted', models.BooleanField(db_index=True, default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('title', models.CharField(db_index=True, max_length=500)), + ('title_ar', models.CharField(blank=True, max_length=500, verbose_name='Title (Arabic)')), + ('file', models.FileField(max_length=500, upload_to=apps.references.models.document_upload_path)), + ('filename', models.CharField(help_text='Original filename', max_length=500)), + ('file_type', models.CharField(blank=True, help_text='File extension/type (e.g., pdf, docx, xlsx)', max_length=50)), + ('file_size', models.IntegerField(help_text='File size in bytes')), + ('description', models.TextField(blank=True)), + ('description_ar', models.TextField(blank=True, verbose_name='Description (Arabic)')), + ('version', models.CharField(default='1.0', help_text='Document version (e.g., 1.0, 1.1, 2.0)', max_length=20)), + ('is_latest_version', models.BooleanField(db_index=True, default=True, help_text='Is this the latest version?')), + ('download_count', models.IntegerField(default=0, help_text='Number of downloads')), + ('last_accessed_at', models.DateTimeField(blank=True, null=True)), + ('is_published', models.BooleanField(db_index=True, default=True, help_text='Is this document visible to users?')), + ('tags', models.CharField(blank=True, help_text='Comma-separated tags for search (e.g., policy, procedure, handbook)', max_length=500)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('access_roles', models.ManyToManyField(blank=True, help_text='Roles that can access this document (empty = all roles)', related_name='accessible_documents', to='auth.group')), + ('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_%(class)s_set', to=settings.AUTH_USER_MODEL)), + ('hospital', models.ForeignKey(help_text='Tenant hospital for this record', on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_related', to='organizations.hospital')), + ('parent_document', models.ForeignKey(blank=True, help_text='Previous version of this document', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='versions', to='references.referencedocument')), + ('uploaded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_documents', to=settings.AUTH_USER_MODEL)), + ('folder', models.ForeignKey(blank=True, help_text='Folder containing this document', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='references.referencefolder')), + ], + options={ + 'verbose_name': 'Reference Document', + 'verbose_name_plural': 'Reference Documents', + 'ordering': ['title', '-created_at'], + }, + ), + migrations.CreateModel( + name='ReferenceDocumentAccess', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('action', models.CharField(choices=[('view', 'Viewed'), ('download', 'Downloaded'), ('preview', 'Previewed')], db_index=True, max_length=20)), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('user_agent', models.TextField(blank=True)), + ('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_logs', to='references.referencedocument')), + ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='document_accesses', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Document Access Log', + 'verbose_name_plural': 'Document Access Logs', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['document', '-created_at'], name='references__documen_396fa2_idx'), models.Index(fields=['user', '-created_at'], name='references__user_id_56bf5f_idx'), models.Index(fields=['action', '-created_at'], name='references__action_b9899d_idx')], + }, + ), + migrations.AddIndex( + model_name='referencefolder', + index=models.Index(fields=['hospital', 'parent', 'order'], name='references__hospita_faa66f_idx'), + ), + migrations.AddIndex( + model_name='referencefolder', + index=models.Index(fields=['hospital', 'is_active'], name='references__hospita_6c43f3_idx'), + ), + migrations.AddIndex( + model_name='referencedocument', + index=models.Index(fields=['hospital', 'folder', 'is_latest_version'], name='references__hospita_36d516_idx'), + ), + migrations.AddIndex( + model_name='referencedocument', + index=models.Index(fields=['hospital', 'is_published'], name='references__hospita_413b58_idx'), + ), + migrations.AddIndex( + model_name='referencedocument', + index=models.Index(fields=['folder', 'title'], name='references__folder__e09b4c_idx'), + ), + ] diff --git a/apps/reports/migrations/0001_initial.py b/apps/reports/migrations/0001_initial.py new file mode 100644 index 0000000..13a52da --- /dev/null +++ b/apps/reports/migrations/0001_initial.py @@ -0,0 +1,141 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('organizations', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ReportTemplate', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200)), + ('description', models.TextField(blank=True)), + ('category', models.CharField(blank=True, max_length=100)), + ('data_source', models.CharField(choices=[('complaints', 'Complaints'), ('inquiries', 'Inquiries'), ('observations', 'Observations'), ('px_actions', 'PX Actions'), ('surveys', 'Surveys'), ('physicians', 'Physician Ratings')], default='complaints', max_length=50)), + ('filter_config', models.JSONField(default=dict)), + ('column_config', models.JSONField(default=list)), + ('grouping_config', models.JSONField(default=dict)), + ('sort_config', models.JSONField(default=list)), + ('chart_config', models.JSONField(default=dict)), + ('icon', models.CharField(blank=True, help_text='Bootstrap icon class', max_length=50)), + ('sort_order', models.IntegerField(default=0)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'ordering': ['sort_order', 'name'], + }, + ), + migrations.CreateModel( + name='SavedReport', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200)), + ('description', models.TextField(blank=True)), + ('data_source', models.CharField(choices=[('complaints', 'Complaints'), ('inquiries', 'Inquiries'), ('observations', 'Observations'), ('px_actions', 'PX Actions'), ('surveys', 'Surveys'), ('physicians', 'Physician Ratings')], default='complaints', max_length=50)), + ('filter_config', models.JSONField(blank=True, default=dict)), + ('column_config', models.JSONField(blank=True, default=list)), + ('grouping_config', models.JSONField(blank=True, default=dict)), + ('sort_config', models.JSONField(blank=True, default=list)), + ('chart_config', models.JSONField(blank=True, default=dict)), + ('is_shared', models.BooleanField(default=False, help_text='Share with other users in the same hospital')), + ('is_template', models.BooleanField(default=False, help_text='Available as a template for others to use')), + ('last_run_at', models.DateTimeField(blank=True, null=True)), + ('last_run_count', models.IntegerField(default=0)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='saved_reports', to=settings.AUTH_USER_MODEL)), + ('hospital', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='saved_reports', to='organizations.hospital')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ReportSchedule', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('frequency', models.CharField(choices=[('once', 'One Time'), ('daily', 'Daily'), ('weekly', 'Weekly'), ('monthly', 'Monthly'), ('quarterly', 'Quarterly')], default='weekly', max_length=20)), + ('delivery_time', models.CharField(default='09:00', help_text='Time to send report (HH:MM format)', max_length=5)), + ('day_of_week', models.IntegerField(blank=True, help_text='For weekly reports: 0=Monday, 6=Sunday', null=True)), + ('day_of_month', models.IntegerField(blank=True, help_text='For monthly reports: 1-31', null=True)), + ('export_format', models.CharField(choices=[('html', 'View Online'), ('pdf', 'PDF'), ('excel', 'Excel'), ('csv', 'CSV')], default='pdf', max_length=20)), + ('recipients', models.JSONField(default=list, help_text='List of email addresses to receive the report')), + ('is_active', models.BooleanField(default=True)), + ('last_run_at', models.DateTimeField(blank=True, null=True)), + ('next_run_at', models.DateTimeField(blank=True, null=True)), + ('last_error', models.TextField(blank=True)), + ('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='schedules', to='reports.savedreport')), + ], + options={ + 'ordering': ['next_run_at'], + }, + ), + migrations.CreateModel( + name='GeneratedReport', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200)), + ('data_source', models.CharField(choices=[('complaints', 'Complaints'), ('inquiries', 'Inquiries'), ('observations', 'Observations'), ('px_actions', 'PX Actions'), ('surveys', 'Surveys'), ('physicians', 'Physician Ratings')], max_length=50)), + ('filter_config', models.JSONField(default=dict)), + ('column_config', models.JSONField(default=list)), + ('grouping_config', models.JSONField(default=dict)), + ('chart_config', models.JSONField(default=dict)), + ('data', models.JSONField(default=dict, help_text='The actual report data')), + ('summary', models.JSONField(default=dict, help_text='Summary statistics')), + ('chart_data', models.JSONField(default=dict, help_text='Chart-formatted data')), + ('row_count', models.IntegerField(default=0)), + ('pdf_file', models.FileField(blank=True, null=True, upload_to='reports/pdf/')), + ('excel_file', models.FileField(blank=True, null=True, upload_to='reports/excel/')), + ('csv_file', models.FileField(blank=True, null=True, upload_to='reports/csv/')), + ('expires_at', models.DateTimeField(blank=True, null=True)), + ('generated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='generated_reports', to=settings.AUTH_USER_MODEL)), + ('hospital', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='generated_reports', to='organizations.hospital')), + ('saved_report', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='generated_reports', to='reports.savedreport')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.AddIndex( + model_name='savedreport', + index=models.Index(fields=['data_source', '-created_at'], name='reports_sav_data_so_d53f3a_idx'), + ), + migrations.AddIndex( + model_name='savedreport', + index=models.Index(fields=['created_by', '-created_at'], name='reports_sav_created_836da5_idx'), + ), + migrations.AddIndex( + model_name='savedreport', + index=models.Index(fields=['hospital', 'is_shared'], name='reports_sav_hospita_6b4af6_idx'), + ), + migrations.AddIndex( + model_name='generatedreport', + index=models.Index(fields=['data_source', '-created_at'], name='reports_gen_data_so_118c97_idx'), + ), + migrations.AddIndex( + model_name='generatedreport', + index=models.Index(fields=['generated_by', '-created_at'], name='reports_gen_generat_f13e26_idx'), + ), + migrations.AddIndex( + model_name='generatedreport', + index=models.Index(fields=['expires_at'], name='reports_gen_expires_a93554_idx'), + ), + ] diff --git a/apps/simulator/migrations/0001_initial.py b/apps/simulator/migrations/0001_initial.py new file mode 100644 index 0000000..1636e38 --- /dev/null +++ b/apps/simulator/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='HISRequestLog', + fields=[ + ('request_id', models.AutoField(primary_key=True, serialize=False)), + ('timestamp', models.DateTimeField(auto_now_add=True, db_index=True)), + ('channel', models.CharField(choices=[('email', 'Email Simulator'), ('sms', 'SMS Simulator'), ('his_event', 'HIS Journey Event')], db_index=True, max_length=20)), + ('payload', models.JSONField(default=dict, help_text='Full request payload')), + ('status', models.CharField(db_index=True, help_text='Request status: success, failed, partial, sent', max_length=20)), + ('response_data', models.JSONField(blank=True, default=dict, help_text='Full response from simulator', null=True)), + ('processing_time_ms', models.IntegerField(blank=True, help_text='Processing time in milliseconds', null=True)), + ('error_message', models.TextField(blank=True, help_text='Error message if request failed', null=True)), + ('patient_id', models.CharField(blank=True, db_index=True, help_text='Patient MRN when applicable', max_length=100, null=True)), + ('journey_id', models.CharField(blank=True, db_index=True, help_text='Journey encounter ID when applicable', max_length=100, null=True)), + ('survey_id', models.CharField(blank=True, db_index=True, help_text='Survey ID when applicable', max_length=100, null=True)), + ('hospital_code', models.CharField(blank=True, db_index=True, help_text='Hospital code when applicable', max_length=50, null=True)), + ('recipient', models.CharField(blank=True, help_text='Email address or phone number', max_length=255, null=True)), + ('subject', models.CharField(blank=True, help_text='Email subject when applicable', max_length=500, null=True)), + ('message_preview', models.TextField(blank=True, help_text='First 500 characters of message', null=True)), + ('event_type', models.CharField(blank=True, db_index=True, help_text='HIS event type code', max_length=100, null=True)), + ('visit_type', models.CharField(blank=True, db_index=True, help_text='Visit type: opd, inpatient, ems', max_length=50, null=True)), + ('department', models.CharField(blank=True, help_text='Department name', max_length=100, null=True)), + ('ip_address', models.GenericIPAddressField(blank=True, help_text='Client IP address', null=True)), + ('user_agent', models.TextField(blank=True, help_text='Client user agent', null=True)), + ], + options={ + 'verbose_name': 'HIS Request Log', + 'verbose_name_plural': 'HIS Request Logs', + 'ordering': ['-timestamp'], + 'indexes': [models.Index(fields=['timestamp', 'channel'], name='simulator_h_timesta_af76d2_idx'), models.Index(fields=['status', 'timestamp'], name='simulator_h_status_354da9_idx')], + }, + ), + ] diff --git a/apps/social/migrations/0001_initial.py b/apps/social/migrations/0001_initial.py new file mode 100644 index 0000000..f89ada8 --- /dev/null +++ b/apps/social/migrations/0001_initial.py @@ -0,0 +1,139 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='SocialAccount', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('platform_type', models.CharField(choices=[('LI', 'LinkedIn'), ('GO', 'Google'), ('META', 'Meta (Facebook/Instagram)'), ('TT', 'TikTok'), ('X', 'X/Twitter'), ('YT', 'YouTube')], max_length=4)), + ('platform_id', models.CharField(help_text='Platform-specific account ID', max_length=255)), + ('name', models.CharField(help_text='Account name or display name', max_length=255)), + ('access_token', models.TextField(blank=True, null=True)), + ('refresh_token', models.TextField(blank=True, null=True)), + ('credentials_json', models.JSONField(blank=True, default=dict)), + ('expires_at', models.DateTimeField(blank=True, null=True)), + ('is_permanent', models.BooleanField(default=False)), + ('is_active', models.BooleanField(default=True)), + ('last_synced_at', models.DateTimeField(blank=True, null=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='social_accounts', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + 'unique_together': {('platform_type', 'platform_id')}, + }, + ), + migrations.CreateModel( + name='SocialContent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('platform_type', models.CharField(max_length=4)), + ('source_platform', models.CharField(blank=True, help_text='Actual source platform for Meta (FB/IG)', max_length=4, null=True)), + ('content_id', models.CharField(help_text='Platform-specific content ID', max_length=255, unique=True)), + ('title', models.CharField(blank=True, help_text='For videos/titles', max_length=255)), + ('text', models.TextField(blank=True, help_text='For posts/tweets')), + ('last_comment_sync_at', models.DateTimeField(default=django.utils.timezone.now)), + ('is_syncing', models.BooleanField(default=False, help_text='Is full sync in progress?')), + ('content_data', models.JSONField(default=dict)), + ('created_at', models.DateTimeField(help_text='Actual content creation time')), + ('added_at', models.DateTimeField(auto_now_add=True)), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contents', to='social.socialaccount')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='SocialComment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('platform_type', models.CharField(max_length=4)), + ('source_platform', models.CharField(blank=True, help_text='Actual source platform for Meta (FB/IG)', max_length=4, null=True)), + ('comment_id', models.CharField(help_text='Platform-specific comment ID', max_length=255, unique=True)), + ('author_name', models.CharField(max_length=255)), + ('author_id', models.CharField(blank=True, max_length=255, null=True)), + ('text', models.TextField()), + ('comment_data', models.JSONField(default=dict)), + ('like_count', models.IntegerField(default=0, help_text='Number of likes')), + ('reply_count', models.IntegerField(default=0, help_text='Number of replies')), + ('rating', models.IntegerField(blank=True, db_index=True, help_text='Star rating (1-5) for review platforms like Google Reviews', null=True)), + ('media_url', models.URLField(blank=True, help_text='URL to associated media (images/videos)', max_length=500, null=True)), + ('ai_analysis', models.JSONField(blank=True, default=dict, help_text='Complete AI analysis in bilingual format (en/ar) with sentiment, summaries, keywords, topics, entities, and emotions')), + ('created_at', models.DateTimeField(db_index=True)), + ('added_at', models.DateTimeField(auto_now_add=True)), + ('synced_via_webhook', models.BooleanField(default=False)), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='social.socialaccount')), + ('content', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='social.socialcontent')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='SocialReply', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('platform_type', models.CharField(max_length=4)), + ('source_platform', models.CharField(blank=True, help_text='Actual source platform for Meta (FB/IG)', max_length=4, null=True)), + ('reply_id', models.CharField(help_text='Platform-specific reply ID', max_length=255, unique=True)), + ('author_name', models.CharField(max_length=255)), + ('author_id', models.CharField(blank=True, max_length=255, null=True)), + ('text', models.TextField()), + ('reply_data', models.JSONField(default=dict)), + ('created_at', models.DateTimeField(db_index=True)), + ('added_at', models.DateTimeField(auto_now_add=True)), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='social.socialaccount')), + ('comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='social.socialcomment')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.AddIndex( + model_name='socialcontent', + index=models.Index(fields=['account', '-created_at'], name='social_soci_account_fa3cd8_idx'), + ), + migrations.AddIndex( + model_name='socialcontent', + index=models.Index(fields=['platform_type', '-created_at'], name='social_soci_platfor_939cb6_idx'), + ), + migrations.AddIndex( + model_name='socialcomment', + index=models.Index(fields=['account', '-created_at'], name='social_soci_account_e203a7_idx'), + ), + migrations.AddIndex( + model_name='socialcomment', + index=models.Index(fields=['content', '-created_at'], name='social_soci_content_4dc803_idx'), + ), + migrations.AddIndex( + model_name='socialcomment', + index=models.Index(fields=['platform_type', '-created_at'], name='social_soci_platfor_a04b00_idx'), + ), + migrations.AddIndex( + model_name='socialreply', + index=models.Index(fields=['comment', '-created_at'], name='social_soci_comment_1816f0_idx'), + ), + migrations.AddIndex( + model_name='socialreply', + index=models.Index(fields=['account', '-created_at'], name='social_soci_account_adbe60_idx'), + ), + migrations.AddIndex( + model_name='socialreply', + index=models.Index(fields=['platform_type', '-created_at'], name='social_soci_platfor_b71fb5_idx'), + ), + ] diff --git a/apps/standards/migrations/0001_initial.py b/apps/standards/migrations/0001_initial.py new file mode 100644 index 0000000..68a324c --- /dev/null +++ b/apps/standards/migrations/0001_initial.py @@ -0,0 +1,164 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +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): + + initial = True + + dependencies = [ + ('organizations', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ActivityType', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200)), + ('name_ar', models.CharField(blank=True, max_length=200, verbose_name='Name (Arabic)')), + ('description', models.TextField(blank=True)), + ('is_active', models.BooleanField(db_index=True, default=True)), + ], + options={ + 'verbose_name': 'Activity Type', + 'verbose_name_plural': 'Activity Types', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='StandardCategory', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('name_ar', models.CharField(blank=True, max_length=100, verbose_name='Name (Arabic)')), + ('description', models.TextField(blank=True)), + ('order', models.PositiveIntegerField(default=0, help_text='Display order')), + ('is_active', models.BooleanField(db_index=True, default=True)), + ('max_score', models.DecimalField(decimal_places=2, default=0, help_text='Maximum possible score for this category (MOH)', max_digits=8)), + ], + options={ + 'verbose_name': 'Standard Category', + 'verbose_name_plural': 'Standard Categories', + 'ordering': ['order', 'name'], + }, + ), + migrations.CreateModel( + name='StandardSource', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('name_ar', models.CharField(blank=True, max_length=100, verbose_name='Name (Arabic)')), + ('code', models.CharField(max_length=50, unique=True)), + ('description', models.TextField(blank=True)), + ('website', models.URLField(blank=True)), + ('is_active', models.BooleanField(db_index=True, default=True)), + ], + options={ + 'verbose_name': 'Standard Source', + 'verbose_name_plural': 'Standard Sources', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Standard', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('code', models.CharField(db_index=True, help_text='e.g., CBAHI-PS-01, 22.1, 1.1', max_length=50)), + ('title', models.CharField(max_length=500)), + ('title_ar', models.CharField(blank=True, max_length=500, verbose_name='Title (Arabic)')), + ('description', models.TextField(blank=True, help_text='Full description of the standard')), + ('assessment_method', models.CharField(blank=True, choices=[('document_review', 'Document Review'), ('staff_interview', 'Staff Interview'), ('observation', 'Observation'), ('evidence', 'Evidence'), ('quality_committee', 'Quality Committee'), ('multiple', 'Multiple Methods')], help_text='How compliance is assessed', max_length=30)), + ('assessment_method_ar', models.CharField(blank=True, help_text='Arabic assessment method text from source file', max_length=200)), + ('order_within_category', models.PositiveIntegerField(default=0, help_text='Display order within category')), + ('effective_date', models.DateField(blank=True, help_text='When standard becomes effective', null=True)), + ('review_date', models.DateField(blank=True, help_text='Next review date', null=True)), + ('is_active', models.BooleanField(db_index=True, default=True)), + ('is_heading', models.BooleanField(default=False, help_text="True for section headers (e.g. 'القيادة رقم 22: ...')")), + ('is_assessable', models.BooleanField(default=True, help_text='If False, this standard is informational only (no assessment required). Headings are always non-assessable.')), + ('activity_type', models.ForeignKey(blank=True, help_text='Activity type for this standard', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='standards', to='standards.activitytype')), + ('departments', models.ManyToManyField(blank=True, help_text='Select departments this standard applies to (empty = applies to all)', related_name='standards', to='organizations.department')), + ('parent_standard', models.ForeignKey(blank=True, help_text='Parent standard for sub-items like 4.3.1 under 4.3', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sub_standards', to='standards.standard')), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='standards', to='standards.standardcategory')), + ('source', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='standards', to='standards.standardsource')), + ], + options={ + 'verbose_name': 'Standard', + 'verbose_name_plural': 'Standards', + 'ordering': ['source', 'category', 'order_within_category', 'code'], + }, + ), + migrations.CreateModel( + name='StandardCompliance', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('status', models.CharField(choices=[('not_assessed', 'Not Assessed'), ('met', 'Met'), ('partially_met', 'Partially Met'), ('not_met', 'Not Met'), ('not_applicable', 'Not Applicable')], db_index=True, default='not_assessed', max_length=20)), + ('status_ar', models.CharField(blank=True, help_text='Original Arabic status text from source', max_length=50)), + ('last_assessed_date', models.DateField(blank=True, help_text='Date of last assessment', null=True)), + ('notes', models.TextField(blank=True, help_text='Assessment notes')), + ('recommendations', models.TextField(blank=True, help_text='Recommendations / strengths / findings')), + ('evidence_summary', models.TextField(blank=True, help_text='Summary of evidence')), + ('target_status', models.CharField(blank=True, choices=[('not_assessed', 'Not Assessed'), ('met', 'Met'), ('partially_met', 'Partially Met'), ('not_met', 'Not Met'), ('not_applicable', 'Not Applicable')], default='', help_text='Target compliance status', max_length=20)), + ('corrective_action', models.TextField(blank=True, help_text='Corrective action plan')), + ('priority', models.CharField(blank=True, choices=[('high', 'High'), ('medium', 'Medium'), ('low', 'Low')], default='', max_length=10)), + ('target_date', models.CharField(blank=True, default='', help_text='Target completion date (text, from source)', max_length=100)), + ('target_date_actual', models.DateField(blank=True, help_text='Parsed target date', null=True)), + ('score', models.DecimalField(blank=True, decimal_places=2, help_text='Score achieved', max_digits=8, null=True)), + ('max_score', models.DecimalField(blank=True, decimal_places=2, help_text='Maximum possible score', max_digits=8, null=True)), + ('assessment_code', models.CharField(blank=True, choices=[('TM', 'Fully Met'), ('TM2', 'Met (Level 2)'), ('TM3', 'Met (Level 3)'), ('PM', 'Partially Met'), ('PM2', 'Partially Met (Level 2)'), ('PM3', 'Partially Met (Level 3)'), ('NM', 'Not Met'), ('NM2', 'Not Met (Level 2)'), ('NM3', 'Not Met (Level 3)')], default='', help_text='TM/PM/NM assessment code', max_length=5)), + ('assessment_code_target', models.CharField(blank=True, default='', help_text='Target assessment code (col D in MOH framework)', max_length=5)), + ('supporting_documents', models.TextField(blank=True, help_text='Supporting document references')), + ('action_note', models.TextField(blank=True, help_text='Action notes / improvement suggestions')), + ('assessor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assessments', to=settings.AUTH_USER_MODEL)), + ('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='compliance_records', to='organizations.department')), + ('hospital', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='standard_compliance_records', to='organizations.hospital')), + ('standard', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='compliance_records', to='standards.standard')), + ], + options={ + 'verbose_name': 'Standard Compliance', + 'verbose_name_plural': 'Standard Compliance', + 'ordering': ['-created_at'], + 'unique_together': {('hospital', 'standard')}, + }, + ), + migrations.CreateModel( + name='StandardAttachment', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('file', models.FileField(upload_to='standards/attachments/%Y/%m/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['pdf', 'doc', 'docx', 'xls', 'xlsx', 'jpg', 'jpeg', 'png', 'zip'])])), + ('filename', models.CharField(help_text='Original filename', max_length=255)), + ('description', models.TextField(blank=True, help_text='Attachment description')), + ('standard', models.ForeignKey(blank=True, help_text='Direct evidence attached to the standard itself', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='direct_attachments', to='standards.standard')), + ('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_standards_attachments', to=settings.AUTH_USER_MODEL)), + ('compliance', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='standards.standardcompliance')), + ], + options={ + 'verbose_name': 'Standard Attachment', + 'verbose_name_plural': 'Standard Attachments', + 'ordering': ['-created_at'], + }, + ), + migrations.AddField( + model_name='standardcategory', + name='source', + field=models.ForeignKey(blank=True, help_text='Which source this category belongs to', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='categories', to='standards.standardsource'), + ), + ] diff --git a/apps/surveys/migrations/0001_initial.py b/apps/surveys/migrations/0001_initial.py new file mode 100644 index 0000000..639896d --- /dev/null +++ b/apps/surveys/migrations/0001_initial.py @@ -0,0 +1,242 @@ +# Generated by Django 6.0.1 on 2026-05-11 20:32 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('journeys', '0001_initial'), + ('organizations', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='SurveyTemplate', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200)), + ('name_ar', models.CharField(blank=True, max_length=200, verbose_name='Name (Arabic)')), + ('survey_type', models.CharField(choices=[('stage', 'Journey Stage Survey'), ('complaint_resolution', 'Complaint Resolution Satisfaction'), ('general', 'General Feedback'), ('nps', 'Net Promoter Score')], db_index=True, default='stage', max_length=50)), + ('scoring_method', models.CharField(choices=[('average', 'Average Score'), ('weighted', 'Weighted Average'), ('nps', 'NPS Calculation')], default='average', max_length=20)), + ('negative_threshold', models.DecimalField(decimal_places=1, default=3.0, help_text='Scores below this trigger PX actions (out of 5)', max_digits=3)), + ('is_active', models.BooleanField(db_index=True, default=True)), + ('instructions_en', models.TextField(blank=True, verbose_name='Instructions (English)')), + ('instructions_ar', models.TextField(blank=True, verbose_name='Instructions (Arabic)')), + ('consent_text_en', models.TextField(blank=True, verbose_name='Consent Text (English)')), + ('consent_text_ar', models.TextField(blank=True, verbose_name='Consent Text (Arabic)')), + ('requires_consent', models.BooleanField(default=True, verbose_name='Require patient consent')), + ('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='survey_templates', to='organizations.hospital')), + ], + options={ + 'ordering': ['hospital', 'name'], + }, + ), + migrations.CreateModel( + name='SurveyQuestion', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('text', models.TextField(verbose_name='Question Text (English)')), + ('text_ar', models.TextField(blank=True, verbose_name='Question Text (Arabic)')), + ('question_type', models.CharField(choices=[('rating', 'Rating (1-5 stars)'), ('nps', 'NPS (0-10)'), ('yes_no', 'Yes/No'), ('multiple_choice', 'Multiple Choice'), ('text', 'Text (Short Answer)'), ('textarea', 'Text Area (Long Answer)'), ('likert', 'Likert Scale (1-5)')], default='rating', max_length=20)), + ('order', models.IntegerField(default=0, help_text='Display order')), + ('is_required', models.BooleanField(default=True)), + ('choices_json', models.JSONField(blank=True, default=list, help_text='Array of choice objects')), + ('is_base', models.BooleanField(db_index=True, default=False, help_text='Always include this question regardless of events')), + ('event_type', models.CharField(blank=True, db_index=True, help_text="HIS event type that triggers this question (e.g., 'Lab Bill', 'Triage')", max_length=200)), + ('is_conditional', models.BooleanField(db_index=True, default=False, help_text='If True, this question is hidden by default and only shown via routing rules')), + ('survey_template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='surveys.surveytemplate')), + ], + options={ + 'ordering': ['survey_template', 'order'], + }, + ), + migrations.CreateModel( + name='SurveyInstance', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('encounter_id', models.CharField(blank=True, db_index=True, max_length=100)), + ('delivery_channel', models.CharField(choices=[('sms', 'SMS'), ('whatsapp', 'WhatsApp'), ('email', 'Email')], default='sms', max_length=20)), + ('recipient_phone', models.CharField(blank=True, max_length=20, null=True)), + ('recipient_email', models.EmailField(blank=True, max_length=254, null=True)), + ('access_token', models.CharField(blank=True, help_text='Secure token for survey access', max_length=100, unique=True)), + ('token_expires_at', models.DateTimeField(blank=True, help_text='Token expiration date', null=True)), + ('status', models.CharField(choices=[('pending', 'Pending (Scheduled, Not Yet Sent)'), ('sent', 'Sent (Not Opened)'), ('viewed', 'Viewed (Opened, Not Started)'), ('in_progress', 'In Progress (Started, Not Completed)'), ('completed', 'Completed'), ('abandoned', 'Abandoned (Started but Left)'), ('expired', 'Expired'), ('cancelled', 'Cancelled'), ('failed', 'Failed')], db_index=True, default='sent', max_length=20)), + ('sent_at', models.DateTimeField(blank=True, db_index=True, null=True)), + ('scheduled_send_at', models.DateTimeField(blank=True, db_index=True, help_text='When this survey should be sent (for delayed sending)', null=True)), + ('opened_at', models.DateTimeField(blank=True, null=True)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ('open_count', models.IntegerField(default=0, help_text='Number of times survey link was opened')), + ('last_opened_at', models.DateTimeField(blank=True, help_text='Most recent time survey was opened', null=True)), + ('time_spent_seconds', models.IntegerField(blank=True, help_text='Total time spent on survey in seconds', null=True)), + ('total_score', models.DecimalField(blank=True, decimal_places=2, help_text='Calculated total score', max_digits=5, null=True)), + ('is_negative', models.BooleanField(db_index=True, default=False, help_text='True if score below threshold')), + ('metadata', models.JSONField(blank=True, default=dict)), + ('patient_contacted', models.BooleanField(default=False, help_text='Whether patient was contacted about negative survey')), + ('patient_contacted_at', models.DateTimeField(blank=True, null=True)), + ('contact_notes', models.TextField(blank=True, help_text='Notes from patient contact')), + ('issue_resolved', models.BooleanField(default=False, help_text='Whether the issue was resolved/explained')), + ('comment', models.TextField(blank=True, help_text='Optional comment from patient')), + ('comment_analyzed', models.BooleanField(default=False, help_text='Whether the comment has been analyzed by AI')), + ('comment_analysis', models.JSONField(blank=True, default=dict, help_text='AI analysis results for the comment')), + ('completed_language', models.CharField(blank=True, help_text='Language selected by patient when completing survey (en/ar)', max_length=10)), + ('hospital', models.ForeignKey(help_text='Tenant hospital for this record', on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_related', to='organizations.hospital')), + ('journey_instance', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='surveys', to='journeys.patientjourneyinstance')), + ('patient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='surveys', to='organizations.patient')), + ('patient_contacted_by', models.ForeignKey(blank=True, help_text='User who contacted the patient', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contacted_surveys', to=settings.AUTH_USER_MODEL)), + ('staff', models.ForeignKey(blank=True, help_text='Staff recipient (if survey is for staff)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='surveys', to='organizations.staff')), + ('survey_template', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='instances', to='surveys.surveytemplate')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='QuestionRoutingRule', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('operator', models.CharField(choices=[('equals', 'Equals'), ('not_equals', 'Not Equals'), ('contains', 'Contains'), ('gt', 'Greater Than'), ('lt', 'Less Than'), ('in_list', 'In List'), ('answered', 'Is Answered'), ('not_answered', 'Is Not Answered')], max_length=20)), + ('value', models.JSONField(blank=True, default=dict, help_text="Comparison value (e.g., 'yes', 3, ['a','b']). Not needed for answered/not_answered.")), + ('action', models.CharField(choices=[('skip_to', 'Skip To Question'), ('end_survey', 'End Survey')], max_length=20)), + ('order', models.IntegerField(default=0, help_text='Evaluation order when multiple rules exist for the same source question')), + ('source_question', models.ForeignKey(help_text='The question whose answer triggers this rule', on_delete=django.db.models.deletion.CASCADE, related_name='outgoing_routing_rules', to='surveys.surveyquestion')), + ('target_question', models.ForeignKey(blank=True, help_text='Target question for skip_to action. Not needed for end_survey.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='incoming_routing_rules', to='surveys.surveyquestion')), + ('survey_template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='routing_rules', to='surveys.surveytemplate')), + ], + options={ + 'ordering': ['source_question', 'order'], + }, + ), + migrations.CreateModel( + name='BulkSurveyJob', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(blank=True, max_length=200)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed'), ('partial', 'Partially Completed')], db_index=True, default='pending', max_length=20)), + ('source', models.CharField(choices=[('his_import', 'HIS Import'), ('csv_upload', 'CSV Upload'), ('manual', 'Manual')], default='manual', max_length=20)), + ('total_patients', models.IntegerField(default=0)), + ('processed_count', models.IntegerField(default=0)), + ('success_count', models.IntegerField(default=0)), + ('failed_count', models.IntegerField(default=0)), + ('delivery_channel', models.CharField(default='sms', max_length=20)), + ('custom_message', models.TextField(blank=True)), + ('patient_data', models.JSONField(default=list, help_text='List of patient IDs and file numbers to process')), + ('results', models.JSONField(blank=True, default=dict, help_text='Detailed results including successes and failures')), + ('error_message', models.TextField(blank=True)), + ('started_at', models.DateTimeField(blank=True, null=True)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bulk_survey_jobs', to=settings.AUTH_USER_MODEL)), + ('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bulk_survey_jobs', to='organizations.hospital')), + ('survey_template', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bulk_jobs', to='surveys.surveytemplate')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='SurveyTracking', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('event_type', models.CharField(choices=[('page_view', 'Page View'), ('survey_started', 'Survey Started'), ('question_answered', 'Question Answered'), ('survey_abandoned', 'Survey Abandoned'), ('survey_completed', 'Survey Completed'), ('reminder_sent', 'Reminder Sent')], db_index=True, max_length=50)), + ('time_on_page', models.IntegerField(blank=True, help_text='Time spent on page in seconds', null=True)), + ('total_time_spent', models.IntegerField(blank=True, help_text='Total time spent on survey so far in seconds', null=True)), + ('current_question', models.IntegerField(blank=True, help_text='Question number when event occurred', null=True)), + ('user_agent', models.TextField(blank=True)), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('device_type', models.CharField(blank=True, help_text='mobile, tablet, desktop', max_length=50)), + ('browser', models.CharField(blank=True, max_length=100)), + ('country', models.CharField(blank=True, max_length=100)), + ('city', models.CharField(blank=True, max_length=100)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('survey_instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tracking_events', to='surveys.surveyinstance')), + ], + options={ + 'ordering': ['survey_instance', 'created_at'], + }, + ), + migrations.CreateModel( + name='SurveyResponse', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('numeric_value', models.DecimalField(blank=True, decimal_places=2, help_text='For rating, NPS, Likert questions', max_digits=10, null=True)), + ('text_value', models.TextField(blank=True, help_text='For text, textarea questions')), + ('choice_value', models.CharField(blank=True, help_text='For multiple choice questions', max_length=200)), + ('response_time_seconds', models.FloatField(blank=True, help_text='Time taken to answer this question in seconds', null=True)), + ('analysis', models.JSONField(blank=True, default=dict, help_text='AI analysis results for text responses')), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='responses', to='surveys.surveyquestion')), + ('survey_instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='surveys.surveyinstance')), + ], + options={ + 'ordering': ['survey_instance', 'question__order'], + 'unique_together': {('survey_instance', 'question')}, + }, + ), + migrations.AddIndex( + model_name='surveytemplate', + index=models.Index(fields=['hospital', 'survey_type', 'is_active'], name='surveys_sur_hospita_0c8e30_idx'), + ), + migrations.AddIndex( + model_name='surveyinstance', + index=models.Index(fields=['patient', '-created_at'], name='surveys_sur_patient_7e68b1_idx'), + ), + migrations.AddIndex( + model_name='surveyinstance', + index=models.Index(fields=['staff', '-created_at'], name='surveys_sur_staff_i_1230b7_idx'), + ), + migrations.AddIndex( + model_name='surveyinstance', + index=models.Index(fields=['status', '-sent_at'], name='surveys_sur_status_ce377b_idx'), + ), + migrations.AddIndex( + model_name='surveyinstance', + index=models.Index(fields=['is_negative', '-completed_at'], name='surveys_sur_is_nega_46c933_idx'), + ), + migrations.AddIndex( + model_name='surveyinstance', + index=models.Index(fields=['survey_template', 'status', '-created_at'], name='surveys_sur_survey__5b13b3_idx'), + ), + migrations.AddIndex( + model_name='questionroutingrule', + index=models.Index(fields=['survey_template', 'source_question', 'order'], name='surveys_que_survey__2afb9a_idx'), + ), + migrations.AddIndex( + model_name='bulksurveyjob', + index=models.Index(fields=['status', '-created_at'], name='surveys_bul_status_e13dfa_idx'), + ), + migrations.AddIndex( + model_name='bulksurveyjob', + index=models.Index(fields=['created_by', '-created_at'], name='surveys_bul_created_d83e06_idx'), + ), + migrations.AddIndex( + model_name='bulksurveyjob', + index=models.Index(fields=['hospital', '-created_at'], name='surveys_bul_hospita_0357db_idx'), + ), + migrations.AddIndex( + model_name='surveytracking', + index=models.Index(fields=['survey_instance', 'event_type', '-created_at'], name='surveys_sur_survey__9743a1_idx'), + ), + migrations.AddIndex( + model_name='surveytracking', + index=models.Index(fields=['event_type', '-created_at'], name='surveys_sur_event_t_885d23_idx'), + ), + ] diff --git a/docker-compose.staging-test.yml b/docker-compose.staging-test.yml new file mode 100644 index 0000000..bebbade --- /dev/null +++ b/docker-compose.staging-test.yml @@ -0,0 +1,148 @@ +services: + caddy: + image: caddy:2-alpine + container_name: px360_test_caddy + restart: unless-stopped + ports: + - "8080:80" + - "8443:443" + volumes: + - ./Caddyfile.test:/etc/caddy/Caddyfile:ro + - test_caddy_data:/data + - test_caddy_config:/config + - test_static_volume:/srv/static + - test_media_volume:/srv/media + depends_on: + web: + condition: service_healthy + networks: + - px360_test_net + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + web: + image: ${PX360_IMAGE:-gitea.tenhal.sa/marwan/hh:latest} + container_name: px360_test_web + restart: unless-stopped + volumes: + - test_static_volume:/app/staticfiles + - test_media_volume:/app/media + env_file: + - .env.staging + environment: + - DB_HOST=db + - DB_PORT=5432 + - DB_USER=${POSTGRES_USER:-px360} + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + networks: + - px360_test_net + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + celery: + image: ${PX360_IMAGE:-gitea.tenhal.sa/marwan/hh:latest} + container_name: px360_test_celery + restart: unless-stopped + command: celery -A config worker -l info --concurrency=2 + env_file: + - .env.staging + environment: + - DB_HOST=db + - DB_PORT=5432 + - DB_USER=${POSTGRES_USER:-px360} + depends_on: + web: + condition: service_healthy + networks: + - px360_test_net + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + celery-beat: + image: ${PX360_IMAGE:-gitea.tenhal.sa/marwan/hh:latest} + container_name: px360_test_celery_beat + restart: unless-stopped + command: celery -A config beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler + env_file: + - .env.staging + environment: + - DB_HOST=db + - DB_PORT=5432 + - DB_USER=${POSTGRES_USER:-px360} + depends_on: + web: + condition: service_healthy + networks: + - px360_test_net + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + db: + image: postgres:15-alpine + container_name: px360_test_db + restart: unless-stopped + ports: + - "5434:5432" + volumes: + - test_postgres_data:/var/lib/postgresql/data + environment: + - POSTGRES_DB=${POSTGRES_DB:-px360} + - POSTGRES_USER=${POSTGRES_USER:-px360} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-px360} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-px360}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - px360_test_net + + redis: + image: redis:7-alpine + container_name: px360_test_redis + restart: unless-stopped + ports: + - "6380:6379" + volumes: + - test_redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - px360_test_net + +volumes: + test_postgres_data: + test_redis_data: + test_static_volume: + test_media_volume: + test_caddy_data: + test_caddy_config: + +networks: + px360_test_net: + driver: bridge diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index 4e7d741..02eb427 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -24,7 +24,7 @@ services: max-file: "3" web: - image: ${PX360_IMAGE:-10.10.1.132:3000/marwan/hh:staging} + image: ${PX360_IMAGE:-gitea.tenhal.sa/marwan/hh:latest} container_name: px360_web restart: unless-stopped volumes: @@ -56,7 +56,7 @@ services: max-file: "3" celery: - image: ${PX360_IMAGE:-10.10.1.132:3000/marwan/hh:staging} + image: ${PX360_IMAGE:-gitea.tenhal.sa/marwan/hh:latest} container_name: px360_celery restart: unless-stopped command: celery -A config worker -l info --concurrency=2 @@ -78,7 +78,7 @@ services: max-file: "3" celery-beat: - image: ${PX360_IMAGE:-10.10.1.132:3000/marwan/hh:staging} + image: ${PX360_IMAGE:-gitea.tenhal.sa/marwan/hh:latest} container_name: px360_celery_beat restart: unless-stopped command: celery -A config beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler