diff --git a/.DS_Store b/.DS_Store index 4d666203..b09969c2 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.clinerules/01-coding.md b/.clinerules/01-coding.md new file mode 100644 index 00000000..85411c62 --- /dev/null +++ b/.clinerules/01-coding.md @@ -0,0 +1,175 @@ +# Hospital Management System v4 - Development Progress + +## Phase 1: Foundation & Infrastructure ✅ COMPLETED + +### Asset Pipeline Setup +**Status:** ✅ COMPLETED +**Date:** 2025-09-22 + +#### What Was Accomplished: +1. **Discovered Existing ColorAdmin Implementation** + - Found comprehensive ColorAdmin setup already in place + - Existing assets: `app.min.js`, `vendor.min.js`, `htmx.min.js`, `vendor.min.css` + - Complete plugin ecosystem with Bootstrap, jQuery, FontAwesome, etc. + +2. **Enhanced Django Integration** + - ✅ Added `django-webpack-loader` to requirements.txt + - ✅ Updated Django settings to include webpack_loader + - ✅ Added webpack loader configuration + - ✅ Created webpack-stats.json for existing ColorAdmin assets + +3. **HTMX Configuration & Enhancement** + - ✅ Enhanced `static/js/custom.js` with comprehensive HTMX configuration + - ✅ Added CSRF token handling for all HTMX requests + - ✅ Added tenant context support for multi-tenant architecture + - ✅ Implemented loading indicators and error handling + - ✅ Added auto-refresh functionality + - ✅ Enhanced form validation and confirmation dialogs + +4. **Base Template Updates** + - ✅ Updated `templates/base.html` to load webpack_loader + - ✅ Added tenant ID data attribute for multi-tenant support + - ✅ Added CSRF token for security + - ✅ Added debug mode detection + +5. **Hospital App Framework** + - ✅ Created global `HospitalApp` object with utility functions + - ✅ Added module registry system for extensibility + - ✅ Enhanced toast notification system + - ✅ Added keyboard shortcuts (Ctrl+S for forms, Escape for modals) + - ✅ Improved API helper functions with better error handling + +#### Key Features Implemented: +- **HTMX Integration:** Full HTMX configuration with error handling, loading states, and auto-refresh +- **Multi-tenant Support:** Tenant ID injection and context handling +- **Enhanced UX:** Toast notifications, loading indicators, keyboard shortcuts +- **Security:** CSRF token handling, proper error responses +- **Extensibility:** Module registry system for future enhancements +- **Backward Compatibility:** Maintained existing ColorAdmin functionality + +#### Files Modified/Created: +- `requirements.txt` - Added django-webpack-loader +- `hospital_management/settings.py` - Added webpack_loader configuration +- `webpack-stats.json` - Asset mapping for existing ColorAdmin files +- `static/js/custom.js` - Enhanced with HTMX and Hospital App features +- `templates/base.html` - Updated with webpack_loader and enhancements +- `package.json` - Created for future asset pipeline needs +- `webpack.config.js` - Created for future webpack builds +- `assets/` directory structure - Created for future development + +#### Next Steps for Phase 2: +1. **HTMX Migration Strategy** - Replace 300+ commented JS functions with HTMX endpoints +2. **Multi-tenant Data Isolation** - Ensure consistent tenant scoping across all models +3. **Missing Model Fields** - Add PRD-required fields to existing models +4. **API Completeness** - Standardize DRF ViewSets and add JWT/OAuth2 + +#### Technical Notes: +- ColorAdmin assets are already optimized and minified +- HTMX is properly configured with global settings +- All existing functionality preserved while adding new capabilities +- Ready for Phase 2 implementation + +--- + +## Phase 2: Core System Enhancements +**Status:** 🔄 IN PROGRESS +**Prerequisites:** Phase 1 ✅ COMPLETED +**Started:** 2025-09-23 + +### Priority Activities: +1. **HTMX Migration Strategy** - Replace 300+ commented JS functions with HTMX endpoints +2. **Multi-tenant Data Isolation** - Ensure consistent tenant scoping across all models +3. **Missing Model Fields** - Add PRD-required fields to existing models +4. **API Completeness** - Standardize DRF ViewSets and add JWT/OAuth2 + +### Current Focus: HTMX Migration Strategy ✅ IN PROGRESS +**Started:** 2025-09-23 + +#### Completed Activities: +1. **HTMX Endpoints Created** ✅ + - Added 8 new HTMX endpoints to `inpatients/views.py` + - `htmx_bed_management_stats` - Real-time bed statistics + - `htmx_filter_beds` - Dynamic bed filtering + - `htmx_bed_details_modal` - Bed details modal content + - `htmx_update_bed_status_form` - Bed status update form + - `htmx_bulk_bed_actions` - Bulk operations on beds + - `htmx_export_bed_data` - Data export functionality + - `htmx_schedule_maintenance` - Maintenance scheduling + - `htmx_view_alerts` - Alert system + +2. **URL Patterns Added** ✅ + - Added 8 new HTMX URL patterns to `inpatients/urls.py` + - All endpoints follow `/htmx/` prefix convention + +3. **Partial Templates Created** ✅ + - `bed_stats.html` - Real-time statistics partial + - More partials needed for complete functionality + +4. **Template Migration Started** ✅ + - Updated `bed_management.html` to use HTMX for statistics + - Replaced static stats with auto-refreshing HTMX endpoint + - Added loading indicators and proper error handling + +#### JavaScript Functions Replaced: +- ✅ `updateBedStatuses()` → HTMX auto-refresh every 30s +- 🔄 `filterBeds()` → HTMX filtering endpoint (in progress) +- 🔄 `viewBedDetails()` → HTMX modal content (in progress) +- 🔄 `bulkUpdate()` → HTMX bulk actions (in progress) +- 🔄 `exportData()` → HTMX export endpoint (in progress) +- 🔄 `scheduleMaintenance()` → HTMX maintenance form (in progress) +- 🔄 `viewAlerts()` → HTMX alerts system (in progress) + +#### Next Steps: +1. Create remaining partial templates +2. Replace filter functionality with HTMX +3. Implement bed details modal with HTMX +4. Add bulk actions functionality +5. Complete export and maintenance features +6. Move to other inpatients templates + +--- + +## Development Guidelines + +### HTMX Usage Patterns: +```html + + + + +
+ Status content +
+ + + +``` + +### Module Registration: +```javascript +// Register a new module +HospitalApp.registerModule('bedManagement', { + init: function() { + // Module initialization + }, + initElement: function(element) { + // Element-specific initialization + } +}); +``` + +### Toast Notifications: +```javascript +// Show different types of notifications +HospitalApp.utils.showToast('Success message', 'success'); +HospitalApp.utils.showToast('Error message', 'error'); +HospitalApp.utils.showToast('Warning message', 'warning'); +HospitalApp.utils.showToast('Info message', 'info'); diff --git a/.clinerules/02-documentation.md b/.clinerules/02-documentation.md new file mode 100644 index 00000000..e69de29b diff --git a/CENTRALIZED_INVENTORY_IMPLEMENTATION.md b/CENTRALIZED_INVENTORY_IMPLEMENTATION.md new file mode 100644 index 00000000..fcfccaed --- /dev/null +++ b/CENTRALIZED_INVENTORY_IMPLEMENTATION.md @@ -0,0 +1,216 @@ +# Centralized Inventory Management Implementation + +## Overview + +This document outlines the successful implementation of a centralized inventory management system for the hospital management system, specifically addressing the consolidation of pharmacy inventory with the main inventory system. + +## Problem Statement + +The original system had duplicate `InventoryItem` models in both the `pharmacy` and `inventory` apps, leading to: +- Data inconsistency +- Duplicate inventory tracking +- Maintenance complexity +- Lack of centralized inventory visibility + +## Solution Architecture + +### 1. Centralized Inventory Model Structure + +**Primary Models (inventory app):** +- `InventoryItem` - Master inventory item catalog +- `InventoryStock` - Stock levels by location and lot +- `InventoryLocation` - Storage locations +- `Supplier` - Vendor management +- `PurchaseOrder` / `PurchaseOrderItem` - Procurement workflow + +**Bridge Model (pharmacy app):** +- `MedicationInventoryItem` - Links medications to centralized inventory with pharmacy-specific metadata + +### 2. Key Changes Made + +#### A. Enhanced Inventory App Models +- Added `PHARMACY_MEDICATIONS` category to `InventoryItem.ItemCategory` +- Enhanced fields to support medication-specific requirements +- Maintained comprehensive supplier and location management + +#### B. Created Pharmacy Bridge Model +```python +class MedicationInventoryItem(models.Model): + """ + Bridge model linking medications to centralized inventory system. + """ + tenant = models.ForeignKey('core.Tenant', ...) + medication = models.ForeignKey(Medication, ...) + inventory_item = models.ForeignKey('inventory.InventoryItem', ...) + + # Pharmacy-specific fields + formulary_tier = models.CharField(...) + therapeutic_equivalent = models.BooleanField(...) + auto_substitution_allowed = models.BooleanField(...) + max_dispense_quantity = models.PositiveIntegerField(...) + requires_counseling = models.BooleanField(...) + requires_id_verification = models.BooleanField(...) + pharmacy_notes = models.TextField(...) +``` + +#### C. Updated Related Models +- `DispenseRecord` now references `inventory.InventoryStock` instead of pharmacy `InventoryItem` +- Maintained backward compatibility with legacy alias: `InventoryItem = MedicationInventoryItem` + +#### D. Updated Forms and Views +- Modified `DispenseRecordForm` to work with `InventoryStock` +- Created `MedicationInventoryItemForm` for bridge model management +- Updated form validation to ensure medication-stock compatibility + +### 3. Data Migration Strategy + +Created `migration_script.py` with functions for: +- Creating default pharmacy locations and suppliers +- Migrating existing pharmacy inventory data to centralized system +- Creating bridge records between medications and inventory items +- Validating migration success + +### 4. Benefits Achieved + +#### Centralization Benefits +- Single source of truth for all inventory +- Unified reporting and analytics +- Consistent procurement workflows +- Centralized location management + +#### Pharmacy-Specific Benefits +- Maintains medication-specific functionality +- Preserves pharmacy workflow requirements +- Supports controlled substance tracking +- Enables formulary management + +#### System Benefits +- Eliminates data duplication +- Reduces maintenance overhead +- Improves data consistency +- Enables cross-department inventory visibility + +## Implementation Details + +### Field Mapping + +| Old Pharmacy InventoryItem | New Structure | +|---------------------------|---------------| +| `medication` | `MedicationInventoryItem.medication` | +| `lot_number` | `InventoryStock.lot_number` | +| `expiration_date` | `InventoryStock.expiration_date` | +| `quantity_on_hand` | `InventoryStock.quantity_on_hand` | +| `quantity_allocated` | `InventoryStock.quantity_reserved` | +| `storage_location` | `InventoryLocation` reference | +| `unit_cost` | `InventoryStock.unit_cost` | +| `supplier` | `Supplier` reference | +| `reorder_point` | `InventoryItem.reorder_point` | +| `reorder_quantity` | `InventoryItem.reorder_quantity` | + +### Relationship Structure + +``` +Medication (pharmacy) + ↓ (one-to-many) +MedicationInventoryItem (pharmacy) + ↓ (many-to-one) +InventoryItem (inventory) + ↓ (one-to-many) +InventoryStock (inventory) + ↓ (many-to-one) +InventoryLocation (inventory) +``` + +### Dispensing Workflow + +1. Prescription created with `Medication` +2. Pharmacist selects appropriate `InventoryStock` for dispensing +3. `DispenseRecord` created linking prescription to specific stock +4. Stock quantities automatically updated +5. Reorder alerts triggered when stock falls below threshold + +## Files Modified + +### Models +- `inventory/models.py` - Enhanced with pharmacy category +- `pharmacy/models.py` - Replaced InventoryItem with MedicationInventoryItem bridge + +### Forms +- `pharmacy/forms.py` - Updated to work with centralized inventory + +### Migration +- `migration_script.py` - Data migration utilities + +## Testing Recommendations + +### Functional Testing +- [ ] Medication creation and inventory item linking +- [ ] Prescription dispensing workflow +- [ ] Stock level tracking and updates +- [ ] Reorder point notifications +- [ ] Lot tracking and expiration management + +### Data Integrity Testing +- [ ] Foreign key relationships +- [ ] Cascade deletion behavior +- [ ] Data consistency across models +- [ ] Migration script validation + +### Performance Testing +- [ ] Query performance with new relationships +- [ ] Dashboard loading times +- [ ] Reporting query efficiency + +## Deployment Steps + +1. **Backup Database** + ```bash + python manage.py dumpdata > backup_before_migration.json + ``` + +2. **Run Migrations** + ```bash + python manage.py makemigrations inventory + python manage.py makemigrations pharmacy + python manage.py migrate + ``` + +3. **Execute Data Migration** + ```bash + python migration_script.py + ``` + +4. **Validate Migration** + - Check data integrity + - Test key workflows + - Verify reporting functionality + +5. **Update Documentation** + - User guides + - API documentation + - Training materials + +## Future Enhancements + +### Phase 2 Improvements +- Integration with other departments (lab, radiology) +- Advanced analytics and reporting +- Automated reordering workflows +- Barcode scanning integration + +### System Optimizations +- Database indexing optimization +- Caching strategies for frequently accessed data +- API performance improvements + +## Conclusion + +The centralized inventory management implementation successfully consolidates pharmacy inventory with the main hospital inventory system while preserving pharmacy-specific functionality. This architecture provides a scalable foundation for future enhancements and ensures data consistency across the entire hospital management system. + +The bridge model approach allows for: +- Seamless integration without losing pharmacy-specific features +- Backward compatibility during transition +- Clear separation of concerns between general inventory and pharmacy operations +- Extensibility for future department integrations + +This implementation represents a significant improvement in system architecture and data management for the hospital management system. diff --git a/DATA_GENERATION_README.md b/DATA_GENERATION_README.md new file mode 100644 index 00000000..4e9f65bc --- /dev/null +++ b/DATA_GENERATION_README.md @@ -0,0 +1,307 @@ +# Saudi Healthcare Data Generation System + +A comprehensive, refactored data generation system for Saudi healthcare applications with proper dependency management and code deduplication. + +## 🎯 Overview + +This system generates realistic test data for a Saudi healthcare management system. It has been completely refactored to eliminate code duplication and provide a unified, maintainable solution. + +### Key Improvements +- **60% code reduction** through shared utilities +- **Dependency management** ensures correct execution order +- **Saudi-specific data** with authentic names, locations, and healthcare context +- **Modular architecture** with shared constants and generators +- **Progress tracking** and error handling +- **Easy execution** via shell script or Python orchestrator + +## 📁 Project Structure + +``` +data_generation/ +├── data_utils/ # Shared utilities package +│ ├── __init__.py # Package initialization +│ ├── constants.py # All Saudi-specific constants +│ ├── generators.py # Common data generation functions +│ ├── helpers.py # Database utilities and model helpers +│ └── base.py # Base classes and orchestrator +├── populate_all_data.py # Master Python orchestrator +├── populate_data.sh # Shell script for easy execution +├── [individual_data_files].py # Refactored individual generators +└── DATA_GENERATION_README.md # This documentation +``` + +## 🚀 Quick Start + +### Option 1: Shell Script (Recommended) +```bash +# Make script executable (already done) +chmod +x populate_data.sh + +# Run all generators +./populate_data.sh + +# Run specific generators +./populate_data.sh core accounts patients + +# Show available options +./populate_data.sh --help +``` + +### Option 2: Python Orchestrator +```bash +# Run all generators +python3 populate_all_data.py + +# Run specific generators +python3 populate_all_data.py core accounts patients + +# Show execution plan +python3 populate_all_data.py --show-plan + +# List available generators +python3 populate_all_data.py --list-generators +``` + +## 📋 Execution Order & Dependencies + +The system automatically manages dependencies: + +1. **core** → Tenants +2. **accounts** → Users (requires: core) +3. **hr** → Employees/Departments (requires: core, accounts) +4. **patients** → Patients (requires: core) +5. **Clinical Modules** (parallel, require: core, accounts, hr, patients): + - **emr** → Encounters, vitals, problems, care plans, notes + - **lab** → Lab tests, orders, results, specimens + - **radiology** → Imaging studies, orders, reports + - **pharmacy** → Medications, prescriptions, dispensations +6. **appointments** → Appointments (requires: patients + providers) +7. **billing** → Bills, payments, claims (requires: patients + encounters) +8. **inpatients** → Admissions, transfers, discharges (requires: patients + staff) +9. **inventory** → Medical supplies, stock (independent) +10. **facility_management** → Buildings, rooms, assets (management command) + +## 🛠️ Available Generators + +| Generator | Description | Dependencies | +|-----------|-------------|--------------| +| `core` | Tenants and system configuration | None | +| `accounts` | Users, authentication, security | core | +| `hr` | Employees, departments, schedules | core, accounts | +| `patients` | Patient profiles, contacts, insurance | core | +| `emr` | Encounters, vitals, problems, care plans | core, accounts, hr, patients | +| `lab` | Laboratory tests, orders, results | core, accounts, hr, patients | +| `radiology` | Imaging studies, orders, reports | core, accounts, hr, patients | +| `pharmacy` | Medications, prescriptions, dispensations | core, accounts, hr, patients | +| `appointments` | Appointment scheduling and management | core, accounts, hr, patients | +| `billing` | Medical billing, payments, insurance claims | core, accounts, patients | +| `inpatients` | Hospital admissions, transfers, discharges | core, accounts, hr, patients | +| `inventory` | Medical supplies and inventory management | None | +| `facility_management` | Buildings, rooms, assets, maintenance | None | + +## 🎛️ Command Line Options + +### Shell Script Options +```bash +./populate_data.sh [OPTIONS] [GENERATORS...] + +Options: + -h, --help Show help message + -l, --list List available generators + -p, --plan Show execution plan + -v, --validate Validate dependencies only + --tenant-id ID Generate data for specific tenant ID + --tenant-slug SLUG Generate data for specific tenant slug + --skip-validation Skip dependency validation + --dry-run Show what would be done (no execution) +``` + +### Python Orchestrator Options +```bash +python3 populate_all_data.py [OPTIONS] [GENERATORS...] + +Options: + --generators GEN... Specific generators to run + --list-generators List available generators + --show-plan Show execution plan + --validate-only Validate dependencies only + --tenant-id ID Tenant ID to generate data for + --tenant-slug SLUG Tenant slug to generate data for + --skip-validation Skip dependency validation +``` + +## 📊 Data Volume + +Default data volumes (customizable in each generator): + +- **Tenants**: 1-2 +- **Users**: 50-200 per tenant +- **Patients**: 50-200 per tenant +- **Clinical Records**: 100-500 per patient +- **Inventory Items**: 50-200 per tenant +- **Facility Assets**: 50-150 per tenant + +## 🔧 Customization + +### Modifying Data Volumes +Edit the generator classes in individual files: +```python +# In any generator file +def run_generation(self, **kwargs): + # Modify these parameters + users_per_tenant = kwargs.get('users_per_tenant', 100) + patients_per_tenant = kwargs.get('patients_per_tenant', 150) + # ... etc +``` + +### Adding New Generators +1. Create new generator class inheriting from `SaudiHealthcareDataGenerator` +2. Add to `populate_all_data.py` imports and registration +3. Update execution order in `DataGenerationOrchestrator.execution_order` + +### Custom Saudi Data +Add to `data_utils/constants.py`: +```python +# Add new constants +NEW_SAUDI_DATA = [ + # Your Saudi-specific data here +] + +# Update existing lists +SAUDI_CITIES.append("New City") +``` + +## 🏗️ Architecture + +### Shared Utilities (`data_utils/`) + +#### `constants.py` +- All Saudi-specific data constants +- Names, cities, medical terms, etc. +- Centralized for consistency + +#### `generators.py` +- Common data generation functions +- Phone numbers, IDs, dates, names +- Reusable across all generators + +#### `helpers.py` +- Database utilities (`safe_bulk_create`, `validate_tenant_exists`) +- Model field filtering and validation +- Progress tracking and error handling + +#### `base.py` +- `BaseDataGenerator`: Basic functionality +- `SaudiHealthcareDataGenerator`: Saudi-specific base class +- `DataGenerationOrchestrator`: Dependency management + +### Individual Generators +Each generator inherits from `SaudiHealthcareDataGenerator` and implements: +```python +class ExampleGenerator(SaudiHealthcareDataGenerator): + def run_generation(self, **kwargs): + # Your generation logic here + # Use self.generate_saudi_name(), self.safe_bulk_create(), etc. + pass +``` + +## 🧪 Testing + +### Validation Only +```bash +# Check if all dependencies are satisfied +./populate_data.sh --validate + +# Show execution plan without running +./populate_data.sh --plan +``` + +### Dry Run +```bash +# Show what would be done without creating data +./populate_data.sh --dry-run +``` + +### Individual Generators +```bash +# Test specific generators +./populate_data.sh core accounts +python3 populate_all_data.py --generators core patients +``` + +## 🐛 Troubleshooting + +### Common Issues + +1. **"No tenants found"** + - Run core generator first: `./populate_data.sh core` + - Or skip validation: `./populate_data.sh --skip-validation` + +2. **"Django not found"** + - Ensure virtual environment is activated + - Install requirements: `pip install -r requirements.txt` + +3. **"Permission denied"** + - Make script executable: `chmod +x populate_data.sh` + +4. **"Import errors"** + - Ensure you're in the project root directory + - Check that all refactored files exist + +### Debug Mode +```bash +# Run with verbose output +python3 populate_all_data.py --generators core --skip-validation +``` + +## 📈 Performance + +### Optimization Tips +- **Batch Operations**: Uses `bulk_create` for large datasets +- **Progress Tracking**: Real-time progress indicators +- **Error Recovery**: Continues processing after individual failures +- **Memory Efficient**: Processes data in chunks + +### Performance Metrics +- **Small Dataset**: ~50 patients, 2-3 minutes +- **Medium Dataset**: ~200 patients, 5-8 minutes +- **Large Dataset**: ~500+ patients, 15-30 minutes + +## 🔒 Security & Compliance + +### Saudi Healthcare Compliance +- **CBAHI Standards**: Follows Central Board for Accreditation of Healthcare Institutions +- **MOH Guidelines**: Ministry of Health data protection requirements +- **HIPAA-like**: Patient privacy and data security considerations + +### Data Privacy +- **Test Data Only**: All generated data is fictional +- **No Real Patients**: Uses generated Saudi names and demographics +- **Safe Deletion**: Easy cleanup of test data + +## 🤝 Contributing + +### Code Standards +- Use shared utilities from `data_utils/` +- Follow dependency order in orchestrator +- Include progress tracking and error handling +- Document new generators and their dependencies + +### Adding New Data Types +1. Add constants to `data_utils/constants.py` +2. Create generator functions in `data_utils/generators.py` +3. Implement new generator class +4. Register in orchestrator +5. Update documentation + +## 📞 Support + +For issues or questions: +1. Check the execution plan: `./populate_data.sh --plan` +2. Validate dependencies: `./populate_data.sh --validate` +3. Run individual generators for debugging +4. Check logs for specific error messages + +--- + +**Generated with ❤️ for Saudi healthcare systems** diff --git a/__pycache__/accounts_data.cpython-312.pyc b/__pycache__/accounts_data.cpython-312.pyc new file mode 100644 index 00000000..cb9dc84a Binary files /dev/null and b/__pycache__/accounts_data.cpython-312.pyc differ diff --git a/__pycache__/appointments_data.cpython-312.pyc b/__pycache__/appointments_data.cpython-312.pyc new file mode 100644 index 00000000..13d5870a Binary files /dev/null and b/__pycache__/appointments_data.cpython-312.pyc differ diff --git a/__pycache__/billing_data.cpython-312.pyc b/__pycache__/billing_data.cpython-312.pyc new file mode 100644 index 00000000..ea3dfa8f Binary files /dev/null and b/__pycache__/billing_data.cpython-312.pyc differ diff --git a/__pycache__/core_data.cpython-312.pyc b/__pycache__/core_data.cpython-312.pyc new file mode 100644 index 00000000..493df377 Binary files /dev/null and b/__pycache__/core_data.cpython-312.pyc differ diff --git a/__pycache__/emr_data.cpython-312.pyc b/__pycache__/emr_data.cpython-312.pyc new file mode 100644 index 00000000..edbf0cbc Binary files /dev/null and b/__pycache__/emr_data.cpython-312.pyc differ diff --git a/__pycache__/hr_data.cpython-312.pyc b/__pycache__/hr_data.cpython-312.pyc new file mode 100644 index 00000000..8739aac3 Binary files /dev/null and b/__pycache__/hr_data.cpython-312.pyc differ diff --git a/__pycache__/inpatients_data.cpython-312.pyc b/__pycache__/inpatients_data.cpython-312.pyc new file mode 100644 index 00000000..7a033ba5 Binary files /dev/null and b/__pycache__/inpatients_data.cpython-312.pyc differ diff --git a/__pycache__/inventory_data.cpython-312.pyc b/__pycache__/inventory_data.cpython-312.pyc new file mode 100644 index 00000000..3c9465fd Binary files /dev/null and b/__pycache__/inventory_data.cpython-312.pyc differ diff --git a/__pycache__/lab_data.cpython-312.pyc b/__pycache__/lab_data.cpython-312.pyc new file mode 100644 index 00000000..d6d995a9 Binary files /dev/null and b/__pycache__/lab_data.cpython-312.pyc differ diff --git a/__pycache__/patients_data.cpython-312.pyc b/__pycache__/patients_data.cpython-312.pyc new file mode 100644 index 00000000..5fce29fa Binary files /dev/null and b/__pycache__/patients_data.cpython-312.pyc differ diff --git a/__pycache__/pharmacy_data.cpython-312.pyc b/__pycache__/pharmacy_data.cpython-312.pyc new file mode 100644 index 00000000..08a68207 Binary files /dev/null and b/__pycache__/pharmacy_data.cpython-312.pyc differ diff --git a/__pycache__/radiology_data.cpython-312.pyc b/__pycache__/radiology_data.cpython-312.pyc new file mode 100644 index 00000000..cd8fc33e Binary files /dev/null and b/__pycache__/radiology_data.cpython-312.pyc differ diff --git a/accounts/__pycache__/urls.cpython-312.pyc b/accounts/__pycache__/urls.cpython-312.pyc index 6440c2d6..c050c98f 100644 Binary files a/accounts/__pycache__/urls.cpython-312.pyc and b/accounts/__pycache__/urls.cpython-312.pyc differ diff --git a/accounts/__pycache__/views.cpython-312.pyc b/accounts/__pycache__/views.cpython-312.pyc index 81b4fe72..443f0408 100644 Binary files a/accounts/__pycache__/views.cpython-312.pyc and b/accounts/__pycache__/views.cpython-312.pyc differ diff --git a/accounts/api/views.py b/accounts/api/views.py index b057f41c..76791551 100644 --- a/accounts/api/views.py +++ b/accounts/api/views.py @@ -43,11 +43,11 @@ class UserViewSet(viewsets.ModelViewSet): return User.objects.none() def perform_create(self, serializer): - user = serializer.save(tenant=getattr(self.request, 'tenant', None)) + user = serializer.save(tenant=self.request.user.tenant) # Log user creation AuditLogger.log_event( - tenant=getattr(self.request, 'tenant', None), + tenant=self.request.user.tenant, event_type='CREATE', event_category='DATA_MODIFICATION', action='Create User', @@ -62,7 +62,7 @@ class UserViewSet(viewsets.ModelViewSet): # Log user update AuditLogger.log_event( - tenant=getattr(self.request, 'tenant', None), + tenant=self.request.user.tenant, event_type='UPDATE', event_category='DATA_MODIFICATION', action='Update User', @@ -227,7 +227,7 @@ class TwoFactorDeviceViewSet(viewsets.ModelViewSet): # Log device creation AuditLogger.log_event( - tenant=getattr(self.request, 'tenant', None), + tenant=self.request.user.tenant, event_type='CREATE', event_category='SECURITY', action='Create Two-Factor Device', @@ -243,7 +243,7 @@ class TwoFactorDeviceViewSet(viewsets.ModelViewSet): # Log device deletion AuditLogger.log_event( - tenant=getattr(self.request, 'tenant', None), + tenant=self.request.user.tenant, event_type='DELETE', event_category='SECURITY', action='Delete Two-Factor Device', diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py index 5795a6c4..561b4057 100644 --- a/accounts/migrations/0001_initial.py +++ b/accounts/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-09-19 10:58 +# Generated by Django 5.2.6 on 2025-09-26 18:33 import django.contrib.auth.models import django.contrib.auth.validators diff --git a/accounts/migrations/0002_initial.py b/accounts/migrations/0002_initial.py index 900b12c8..c21fcc85 100644 --- a/accounts/migrations/0002_initial.py +++ b/accounts/migrations/0002_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-09-19 10:58 +# Generated by Django 5.2.6 on 2025-09-26 18:33 import django.db.models.deletion from django.conf import settings diff --git a/accounts/migrations/__pycache__/0001_initial.cpython-312.pyc b/accounts/migrations/__pycache__/0001_initial.cpython-312.pyc index 364f8c14..affba34f 100644 Binary files a/accounts/migrations/__pycache__/0001_initial.cpython-312.pyc and b/accounts/migrations/__pycache__/0001_initial.cpython-312.pyc differ diff --git a/accounts/migrations/__pycache__/0002_initial.cpython-312.pyc b/accounts/migrations/__pycache__/0002_initial.cpython-312.pyc index a331fd3c..ec05b689 100644 Binary files a/accounts/migrations/__pycache__/0002_initial.cpython-312.pyc and b/accounts/migrations/__pycache__/0002_initial.cpython-312.pyc differ diff --git a/accounts/urls.py b/accounts/urls.py index 3517b139..a5ea6716 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,16 +4,16 @@ URL configuration for accounts app. from django.urls import path from . import views -# from allauth.account.views import SignupView, LoginView, LogoutView +from allauth.account.views import SignupView, LoginView, LogoutView app_name = 'accounts' urlpatterns = [ - # path('accounts/', include('allauth.urls')), + # Main views - # path('login/', views.AccountLoginView.as_view(), name='login'), - # path('logout/', LogoutView.as_view(), name='logout'), - # path('signup/', SignupView.as_view(), name='account_signup'), + path('login/', LoginView.as_view(), name='login'), + path('logout/', LogoutView.as_view(), name='logout'), + path('signup/', SignupView.as_view(), name='account_signup'), path('users/', views.UserListView.as_view(), name='user_list'), path('users//', views.UserDetailView.as_view(), name='user_detail'), diff --git a/accounts/views.py b/accounts/views.py index d6812db6..1eea0d5f 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -42,7 +42,7 @@ class UserListView(LoginRequiredMixin, ListView): paginate_by = 25 def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return User.objects.none() @@ -79,7 +79,7 @@ class UserListView(LoginRequiredMixin, ListView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if tenant: # Get filter options @@ -101,7 +101,7 @@ class UserDetailView(LoginRequiredMixin, DetailView): context_object_name = 'user_profile' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return User.objects.none() return User.objects.filter(tenant=tenant) @@ -155,7 +155,7 @@ class UserCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView): def form_valid(self, form): # Set tenant - form.instance.tenant = getattr(self.request, 'tenant', None) + form.instance.tenant = self.request.user.tenant response = super().form_valid(form) # Log user creation @@ -184,7 +184,7 @@ class UserUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView): permission_required = 'accounts.change_user' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return User.objects.none() return User.objects.filter(tenant=tenant) @@ -221,7 +221,7 @@ class UserDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteView): success_url = reverse_lazy('accounts:user_list') def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return User.objects.none() return User.objects.filter(tenant=tenant) @@ -263,7 +263,7 @@ class TwoFactorDeviceListView(LoginRequiredMixin, ListView): paginate_by = 20 def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return TwoFactorDevice.objects.none() @@ -288,7 +288,7 @@ class TwoFactorDeviceListView(LoginRequiredMixin, ListView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if tenant: context.update({ @@ -308,7 +308,7 @@ class TwoFactorDeviceDetailView(LoginRequiredMixin, DetailView): context_object_name = 'device' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return TwoFactorDevice.objects.none() return TwoFactorDevice.objects.filter(user__tenant=tenant) @@ -340,7 +340,7 @@ class TwoFactorDeviceCreateView(LoginRequiredMixin, PermissionRequiredMixin, Cre # Log device creation AuditLogger.log_event( - tenant=getattr(self.request, 'tenant', None), + tenant=self.request.user.tenant, event_type='CREATE', event_category='SECURITY', action='Create Two-Factor Device', @@ -364,7 +364,7 @@ class TwoFactorDeviceUpdateView(LoginRequiredMixin, PermissionRequiredMixin, Upd permission_required = 'accounts.change_twofactordevice' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return TwoFactorDevice.objects.none() return TwoFactorDevice.objects.filter(user__tenant=tenant) @@ -382,7 +382,7 @@ class TwoFactorDeviceUpdateView(LoginRequiredMixin, PermissionRequiredMixin, Upd # Log device update AuditLogger.log_event( - tenant=getattr(self.request, 'tenant', None), + tenant=self.request.user.tenant, event_type='UPDATE', event_category='SECURITY', action='Update Two-Factor Device', @@ -406,7 +406,7 @@ class TwoFactorDeviceDeleteView(LoginRequiredMixin, PermissionRequiredMixin, Del success_url = reverse_lazy('accounts:two_factor_device_list') def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return TwoFactorDevice.objects.none() return TwoFactorDevice.objects.filter(user__tenant=tenant) @@ -445,7 +445,7 @@ class SocialAccountListView(LoginRequiredMixin, ListView): paginate_by = 20 def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return SocialAccount.objects.none() @@ -470,7 +470,7 @@ class SocialAccountListView(LoginRequiredMixin, ListView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if tenant: context.update({ @@ -492,7 +492,7 @@ class SocialAccountDetailView(LoginRequiredMixin, DetailView): context_object_name = 'social_account' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return SocialAccount.objects.none() return SocialAccount.objects.filter(user__tenant=tenant) @@ -518,7 +518,7 @@ class SocialAccountCreateView(LoginRequiredMixin, PermissionRequiredMixin, Creat # Log social account creation AuditLogger.log_event( - tenant=getattr(self.request, 'tenant', None), + tenant=self.request.user.tenant, event_type='CREATE', event_category='INTEGRATION', action='Create Social Account', @@ -542,7 +542,7 @@ class SocialAccountUpdateView(LoginRequiredMixin, PermissionRequiredMixin, Updat permission_required = 'accounts.change_socialaccount' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return SocialAccount.objects.none() return SocialAccount.objects.filter(user__tenant=tenant) @@ -560,7 +560,7 @@ class SocialAccountUpdateView(LoginRequiredMixin, PermissionRequiredMixin, Updat # Log social account update AuditLogger.log_event( - tenant=getattr(self.request, 'tenant', None), + tenant=self.request.user.tenant, event_type='UPDATE', event_category='INTEGRATION', action='Update Social Account', @@ -584,7 +584,7 @@ class SocialAccountDeleteView(LoginRequiredMixin, PermissionRequiredMixin, Delet success_url = reverse_lazy('accounts:social_account_list') def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return SocialAccount.objects.none() return SocialAccount.objects.filter(user__tenant=tenant) @@ -623,7 +623,7 @@ class UserSessionListView(LoginRequiredMixin, ListView): paginate_by = 50 def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return UserSession.objects.none() @@ -656,7 +656,7 @@ class UserSessionListView(LoginRequiredMixin, ListView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if tenant: context.update({ @@ -678,7 +678,7 @@ class UserSessionDetailView(LoginRequiredMixin, DetailView): context_object_name = 'session' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return UserSession.objects.none() return UserSession.objects.filter(user__tenant=tenant) @@ -698,7 +698,7 @@ class PasswordHistoryListView(LoginRequiredMixin, ListView): paginate_by = 50 def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return PasswordHistory.objects.none() @@ -721,7 +721,7 @@ class PasswordHistoryListView(LoginRequiredMixin, ListView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if tenant: context.update({ @@ -740,7 +740,7 @@ class PasswordHistoryDetailView(LoginRequiredMixin, DetailView): context_object_name = 'password_history' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return PasswordHistory.objects.none() return PasswordHistory.objects.filter(user__tenant=tenant) @@ -796,7 +796,7 @@ class SessionManagementView(LoginRequiredMixin, ListView): paginate_by = 50 def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return UserSession.objects.none() @@ -1301,7 +1301,7 @@ def upload_avatar(request, pk): # paginate_by = 25 # # def get_queryset(self): -# tenant = getattr(self.request, 'tenant', None) +# tenant = self.request.user.tenant # if not tenant: # return User.objects.none() # @@ -1338,7 +1338,7 @@ def upload_avatar(request, pk): # # def get_context_data(self, **kwargs): # context = super().get_context_data(**kwargs) -# tenant = getattr(self.request, 'tenant', None) +# tenant = self.request.user.tenant # # if tenant: # # Get filter options @@ -1360,7 +1360,7 @@ def upload_avatar(request, pk): # context_object_name = 'user_profile' # # def get_queryset(self): -# tenant = getattr(self.request, 'tenant', None) +# tenant = self.request.user.tenant # if not tenant: # return User.objects.none() # return User.objects.filter(tenant=tenant) @@ -1414,7 +1414,7 @@ def upload_avatar(request, pk): # # def form_valid(self, form): # # Set tenant -# form.instance.tenant = getattr(self.request, 'tenant', None) +# form.instance.tenant = self.request.user.tenant # response = super().form_valid(form) # # # Log user creation @@ -1443,7 +1443,7 @@ def upload_avatar(request, pk): # permission_required = 'accounts.change_user' # # def get_queryset(self): -# tenant = getattr(self.request, 'tenant', None) +# tenant = self.request.user.tenant # if not tenant: # return User.objects.none() # return User.objects.filter(tenant=tenant) @@ -1480,7 +1480,7 @@ def upload_avatar(request, pk): # success_url = reverse_lazy('accounts:user_list') # # def get_queryset(self): -# tenant = getattr(self.request, 'tenant', None) +# tenant = self.request.user.tenant # if not tenant: # return User.objects.none() # return User.objects.filter(tenant=tenant) @@ -1522,7 +1522,7 @@ def upload_avatar(request, pk): # paginate_by = 20 # # def get_queryset(self): -# tenant = getattr(self.request, 'tenant', None) +# tenant = self.request.user.tenant # if not tenant: # return TwoFactorDevice.objects.none() # @@ -1547,7 +1547,7 @@ def upload_avatar(request, pk): # # def get_context_data(self, **kwargs): # context = super().get_context_data(**kwargs) -# tenant = getattr(self.request, 'tenant', None) +# tenant = self.request.user.tenant # # if tenant: # context.update({ @@ -1567,7 +1567,7 @@ def upload_avatar(request, pk): # context_object_name = 'device' # # def get_queryset(self): -# tenant = getattr(self.request, 'tenant', None) +# tenant = self.request.user.tenant # if not tenant: # return TwoFactorDevice.objects.none() # return TwoFactorDevice.objects.filter(user__tenant=tenant) @@ -1599,7 +1599,7 @@ def upload_avatar(request, pk): # # # Log device creation # AuditLogger.log_event( -# tenant=getattr(self.request, 'tenant', None), +# tenant=self.request.user.tenant, # event_type='CREATE', # event_category='SECURITY', # action='Create Two-Factor Device', @@ -1623,7 +1623,7 @@ def upload_avatar(request, pk): # permission_required = 'accounts.change_twofactordevice' # # def get_queryset(self): -# tenant = getattr(self.request, 'tenant', None) +# tenant = self.request.user.tenant # if not tenant: # return TwoFactorDevice.objects.none() # return TwoFactorDevice.objects.filter(user__tenant=tenant) @@ -1641,7 +1641,7 @@ def upload_avatar(request, pk): # # # Log device update # AuditLogger.log_event( -# tenant=getattr(self.request, 'tenant', None), +# tenant=self.request.user.tenant, # event_type='UPDATE', # event_category='SECURITY', # action='Update Two-Factor Device', @@ -1665,7 +1665,7 @@ def upload_avatar(request, pk): # success_url = reverse_lazy('accounts:two_factor_device_list') # # def get_queryset(self): -# tenant = getattr(self.request, 'tenant', None) +# tenant = self.request.user.tenant # if not tenant: # return TwoFactorDevice.objects.none() # return TwoFactorDevice.objects.filter(user__tenant=tenant) @@ -1704,7 +1704,7 @@ def upload_avatar(request, pk): # paginate_by = 20 # # def get_queryset(self): -# tenant = getattr(self.request, 'tenant', None) +# tenant = self.request.user.tenant # if not tenant: # return SocialAccount.objects.none() # @@ -1729,7 +1729,7 @@ def upload_avatar(request, pk): # # def get_context_data(self, **kwargs): # context = super().get_context_data(**kwargs) -# tenant = getattr(self.request, 'tenant', None) +# tenant = self.request.user.tenant # # if tenant: # context.update({ @@ -1751,7 +1751,7 @@ def upload_avatar(request, pk): # context_object_name = 'social_account' # # def get_queryset(self): -# tenant = getattr(self.request, 'tenant', None) +# tenant = self.request.user.tenant # if not tenant: # return SocialAccount.objects.none() # return SocialAccount.objects.filter(user__tenant=tenant) @@ -1777,7 +1777,7 @@ def upload_avatar(request, pk): # # # Log social account creation # AuditLogger.log_event( -# tenant=getattr(self.request, 'tenant', None), +# tenant=self.request.user.tenant, # event_type='CREATE', # event_category='INTEGRATION', # action='Create Social Account', @@ -1801,7 +1801,7 @@ def upload_avatar(request, pk): # permission_required = 'accounts.change_socialaccount' # # def get_queryset(self): -# tenant = getattr(self.request, 'tenant', None) +# tenant = self.request.user.tenant # if not tenant: # return SocialAccount.objects.none() # return SocialAccount.objects.filter(user__tenant=tenant) @@ -1819,7 +1819,7 @@ def upload_avatar(request, pk): # # # Log social account update # AuditLogger.log_event( -# tenant=getattr(self.request, 'tenant', None), +# tenant=self.request.user.tenant, # event_type='UPDATE', # event_category='INTEGRATION', # action='Update Social Account', @@ -1843,7 +1843,7 @@ def upload_avatar(request, pk): # success_url = reverse_lazy('accounts:social_account_list') # # def get_queryset(self): -# tenant = getattr(self.request, 'tenant', None) +# tenant = self.request.user.tenant # if not tenant: # return SocialAccount.objects.none() # return SocialAccount.objects.filter(user__tenant=tenant) @@ -1882,7 +1882,7 @@ def upload_avatar(request, pk): # paginate_by = 50 # # def get_queryset(self): -# tenant = getattr(self.request, 'tenant', None) +# tenant = self.request.user.tenant # if not tenant: # return UserSession.objects.none() # @@ -1915,7 +1915,7 @@ def upload_avatar(request, pk): # # def get_context_data(self, **kwargs): # context = super().get_context_data(**kwargs) -# tenant = getattr(self.request, 'tenant', None) +# tenant = self.request.user.tenant # # if tenant: # context.update({ @@ -1937,7 +1937,7 @@ def upload_avatar(request, pk): # context_object_name = 'session' # # def get_queryset(self): -# tenant = getattr(self.request, 'tenant', None) +# tenant = self.request.user.tenant # if not tenant: # return UserSession.objects.none() # return UserSession.objects.filter(user__tenant=tenant) @@ -1957,7 +1957,7 @@ def upload_avatar(request, pk): # paginate_by = 50 # # def get_queryset(self): -# tenant = getattr(self.request, 'tenant', None) +# tenant = self.request.user.tenant # if not tenant: # return PasswordHistory.objects.none() # @@ -1980,7 +1980,7 @@ def upload_avatar(request, pk): # # def get_context_data(self, **kwargs): # context = super().get_context_data(**kwargs) -# tenant = getattr(self.request, 'tenant', None) +# tenant = self.request.user.tenant # # if tenant: # context.update({ @@ -1999,7 +1999,7 @@ def upload_avatar(request, pk): # context_object_name = 'password_history' # # def get_queryset(self): -# tenant = getattr(self.request, 'tenant', None) +# tenant = self.request.user.tenant # if not tenant: # return PasswordHistory.objects.none() # return PasswordHistory.objects.filter(user__tenant=tenant) @@ -2055,7 +2055,7 @@ def upload_avatar(request, pk): # paginate_by = 50 # # def get_queryset(self): -# tenant = getattr(self.request, 'tenant', None) +# tenant = self.request.user.tenant # if not tenant: # return UserSession.objects.none() # diff --git a/accounts_data.py b/accounts_data.py index fd5834b8..92b76d86 100644 --- a/accounts_data.py +++ b/accounts_data.py @@ -15,13 +15,10 @@ from django.contrib.auth.hashers import make_password from django.utils import timezone as django_timezone from django.db import transaction -from accounts.models import User +from accounts.models import User, TwoFactorDevice, SocialAccount, UserSession, PasswordHistory from core.models import Tenant -from hr.models import Employee -from hr.models import Department # make sure hr.Department exists +from hr.models import Employee, Department -# If these exist in your project, keep them. If not, comment them out. -from accounts.models import TwoFactorDevice, SocialAccount, UserSession, PasswordHistory # noqa # ------------------------------- # Saudi-specific data constants @@ -162,6 +159,8 @@ def create_saudi_users(tenants, users_per_tenant=50): for _ in range(count): is_male = random.choice([True, False]) first_name = random.choice(SAUDI_FIRST_NAMES_MALE if is_male else SAUDI_FIRST_NAMES_FEMALE) + father_name = random.choice(SAUDI_FIRST_NAMES_MALE) + grandfather_name = random.choice(SAUDI_FIRST_NAMES_MALE) last_name = random.choice(SAUDI_FAMILY_NAMES) # base username like "mohammed.alrashid" @@ -200,8 +199,9 @@ def create_saudi_users(tenants, users_per_tenant=50): emp: Employee = user.employee_profile # created by signal emp.tenant = tenant # ensure alignment emp.first_name = first_name + emp.father_name = father_name + emp.grandfather_name = grandfather_name emp.last_name = last_name - emp.preferred_name = first_name if random.choice([True, False]) else None # Contact (E.164 KSA) mobile = generate_saudi_mobile_e164() diff --git a/analytics/__pycache__/models.cpython-312.pyc b/analytics/__pycache__/models.cpython-312.pyc index 4336b47f..cb37996b 100644 Binary files a/analytics/__pycache__/models.cpython-312.pyc and b/analytics/__pycache__/models.cpython-312.pyc differ diff --git a/analytics/migrations/0001_initial.py b/analytics/migrations/0001_initial.py index 896ab186..5d0e31fc 100644 --- a/analytics/migrations/0001_initial.py +++ b/analytics/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-09-19 10:58 +# Generated by Django 5.2.6 on 2025-09-26 18:33 import django.core.validators import django.db.models.deletion diff --git a/analytics/migrations/0002_initial.py b/analytics/migrations/0002_initial.py index 3d4bb0b8..588591bb 100644 --- a/analytics/migrations/0002_initial.py +++ b/analytics/migrations/0002_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-09-19 10:58 +# Generated by Django 5.2.6 on 2025-09-26 18:33 import django.db.models.deletion from django.conf import settings diff --git a/analytics/migrations/__pycache__/0001_initial.cpython-312.pyc b/analytics/migrations/__pycache__/0001_initial.cpython-312.pyc index e1f894ee..9d3fe860 100644 Binary files a/analytics/migrations/__pycache__/0001_initial.cpython-312.pyc and b/analytics/migrations/__pycache__/0001_initial.cpython-312.pyc differ diff --git a/analytics/migrations/__pycache__/0002_initial.cpython-312.pyc b/analytics/migrations/__pycache__/0002_initial.cpython-312.pyc index 56296539..fe9c3935 100644 Binary files a/analytics/migrations/__pycache__/0002_initial.cpython-312.pyc and b/analytics/migrations/__pycache__/0002_initial.cpython-312.pyc differ diff --git a/analytics/models.py b/analytics/models.py index e32d8da8..2d37eaf4 100644 --- a/analytics/models.py +++ b/analytics/models.py @@ -17,23 +17,23 @@ class Dashboard(models.Model): """ Dashboard model for organizing widgets and analytics views. """ - DASHBOARD_TYPES = [ - ('EXECUTIVE', 'Executive Dashboard'), - ('CLINICAL', 'Clinical Dashboard'), - ('OPERATIONAL', 'Operational Dashboard'), - ('FINANCIAL', 'Financial Dashboard'), - ('QUALITY', 'Quality Dashboard'), - ('PATIENT', 'Patient Dashboard'), - ('PROVIDER', 'Provider Dashboard'), - ('DEPARTMENT', 'Department Dashboard'), - ('CUSTOM', 'Custom Dashboard'), - ] + + class DashboardType(models.TextChoices): + EXECUTIVE = 'EXECUTIVE', 'Executive Dashboard' + CLINICAL = 'CLINICAL', 'Clinical Dashboard' + OPERATIONAL = 'OPERATIONAL', 'Operational Dashboard' + FINANCIAL = 'FINANCIAL', 'Financial Dashboard' + QUALITY = 'QUALITY', 'Quality Dashboard' + PATIENT = 'PATIENT', 'Patient Dashboard' + PROVIDER = 'PROVIDER', 'Provider Dashboard' + DEPARTMENT = 'DEPARTMENT', 'Department Dashboard' + CUSTOM = 'CUSTOM', 'Custom Dashboard' dashboard_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='dashboards') name = models.CharField(max_length=200) description = models.TextField(blank=True) - dashboard_type = models.CharField(max_length=20, choices=DASHBOARD_TYPES) + dashboard_type = models.CharField(max_length=20, choices=DashboardType.choices) # Layout and configuration layout_config = models.JSONField(default=dict, help_text="Dashboard layout configuration") @@ -70,30 +70,29 @@ class DashboardWidget(models.Model): """ Dashboard widget model for individual analytics components. """ - WIDGET_TYPES = [ - ('CHART', 'Chart Widget'), - ('TABLE', 'Table Widget'), - ('METRIC', 'Metric Widget'), - ('GAUGE', 'Gauge Widget'), - ('MAP', 'Map Widget'), - ('TEXT', 'Text Widget'), - ('IMAGE', 'Image Widget'), - ('IFRAME', 'IFrame Widget'), - ('CUSTOM', 'Custom Widget'), - ] - - CHART_TYPES = [ - ('LINE', 'Line Chart'), - ('BAR', 'Bar Chart'), - ('PIE', 'Pie Chart'), - ('DOUGHNUT', 'Doughnut Chart'), - ('AREA', 'Area Chart'), - ('SCATTER', 'Scatter Plot'), - ('HISTOGRAM', 'Histogram'), - ('HEATMAP', 'Heat Map'), - ('TREEMAP', 'Tree Map'), - ('FUNNEL', 'Funnel Chart'), - ] + + class WidgetType(models.TextChoices): + CHART = 'CHART', 'Chart Widget' + TABLE = 'TABLE', 'Table Widget' + METRIC = 'METRIC', 'Metric Widget' + GAUGE = 'GAUGE', 'Gauge Widget' + MAP = 'MAP', 'Map Widget' + TEXT = 'TEXT', 'Text Widget' + IMAGE = 'IMAGE', 'Image Widget' + IFRAME = 'IFRAME', 'IFrame Widget' + CUSTOM = 'CUSTOM', 'Custom Widget' + + class ChartType(models.TextChoices): + LINE = 'LINE', 'Line Chart' + BAR = 'BAR', 'Bar Chart' + PIE = 'PIE', 'Pie Chart' + DOUGHNUT = 'DOUGHNUT', 'Doughnut Chart' + AREA = 'AREA', 'Area Chart' + SCATTER = 'SCATTER', 'Scatter Plot' + HISTOGRAM = 'HISTOGRAM', 'Histogram' + HEATMAP = 'HEATMAP', 'Heat Map' + TREEMAP = 'TREEMAP', 'Tree Map' + FUNNEL = 'FUNNEL', 'Funnel Chart' widget_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) dashboard = models.ForeignKey(Dashboard, on_delete=models.CASCADE, related_name='widgets') @@ -101,8 +100,8 @@ class DashboardWidget(models.Model): # Widget configuration name = models.CharField(max_length=200) description = models.TextField(blank=True) - widget_type = models.CharField(max_length=20, choices=WIDGET_TYPES) - chart_type = models.CharField(max_length=20, choices=CHART_TYPES, blank=True) + widget_type = models.CharField(max_length=20, choices=WidgetType.choices) + chart_type = models.CharField(max_length=20, choices=ChartType.choices, blank=True) # Data source data_source = models.ForeignKey('DataSource', on_delete=models.CASCADE, related_name='widgets') @@ -145,34 +144,33 @@ class DataSource(models.Model): """ Data source model for analytics data connections. """ - SOURCE_TYPES = [ - ('DATABASE', 'Database Query'), - ('API', 'API Endpoint'), - ('FILE', 'File Upload'), - ('STREAM', 'Real-time Stream'), - ('WEBHOOK', 'Webhook'), - ('CUSTOM', 'Custom Source'), - ] - - CONNECTION_TYPES = [ - ('POSTGRESQL', 'PostgreSQL'), - ('MYSQL', 'MySQL'), - ('SQLITE', 'SQLite'), - ('MONGODB', 'MongoDB'), - ('REDIS', 'Redis'), - ('REST_API', 'REST API'), - ('GRAPHQL', 'GraphQL'), - ('WEBSOCKET', 'WebSocket'), - ('CSV', 'CSV File'), - ('JSON', 'JSON File'), - ('XML', 'XML File'), - ] - TEST_STATUS_CHOICES = [ - ('PENDING', 'Pending'), - ('RUNNING', 'Running'), - ('SUCCESS', 'Success'), - ('FAILURE', 'Failure'), - ] + + class SourceType(models.TextChoices): + DATABASE = 'DATABASE', 'Database Query' + API = 'API', 'API Endpoint' + FILE = 'FILE', 'File Upload' + STREAM = 'STREAM', 'Real-time Stream' + WEBHOOK = 'WEBHOOK', 'Webhook' + CUSTOM = 'CUSTOM', 'Custom Source' + + class ConnectionType(models.TextChoices): + POSTGRESQL = 'POSTGRESQL', 'PostgreSQL' + MYSQL = 'MYSQL', 'MySQL' + SQLITE = 'SQLITE', 'SQLite' + MONGODB = 'MONGODB', 'MongoDB' + REDIS = 'REDIS', 'Redis' + REST_API = 'REST_API', 'REST API' + GRAPHQL = 'GRAPHQL', 'GraphQL' + WEBSOCKET = 'WEBSOCKET', 'WebSocket' + CSV = 'CSV', 'CSV File' + JSON = 'JSON', 'JSON File' + XML = 'XML', 'XML File' + + class TestStatus(models.TextChoices): + PENDING = 'PENDING', 'Pending' + RUNNING = 'RUNNING', 'Running' + SUCCESS = 'SUCCESS', 'Success' + FAILURE = 'FAILURE', 'Failure' source_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='data_sources') @@ -180,8 +178,8 @@ class DataSource(models.Model): # Source configuration name = models.CharField(max_length=200) description = models.TextField(blank=True) - source_type = models.CharField(max_length=20, choices=SOURCE_TYPES) - connection_type = models.CharField(max_length=20, choices=CONNECTION_TYPES) + source_type = models.CharField(max_length=20, choices=SourceType.choices) + connection_type = models.CharField(max_length=20, choices=ConnectionType.choices) # Connection details connection_config = models.JSONField(default=dict, help_text="Connection configuration") @@ -202,7 +200,7 @@ class DataSource(models.Model): # Status is_active = models.BooleanField(default=True) - last_test_status = models.CharField(max_length=20, choices=TEST_STATUS_CHOICES, default='PENDING') + last_test_status = models.CharField(max_length=20, choices=TestStatus.choices, default=TestStatus.PENDING) last_test_start_at = models.DateTimeField(null=True, blank=True) last_test_end_at = models.DateTimeField(null=True, blank=True) last_test_duration_seconds = models.PositiveIntegerField(null=True, blank=True) @@ -230,34 +228,32 @@ class Report(models.Model): """ Report model for scheduled and ad-hoc reporting. """ - REPORT_TYPES = [ - ('OPERATIONAL', 'Operational Report'), - ('FINANCIAL', 'Financial Report'), - ('CLINICAL', 'Clinical Report'), - ('QUALITY', 'Quality Report'), - ('COMPLIANCE', 'Compliance Report'), - ('PERFORMANCE', 'Performance Report'), - ('CUSTOM', 'Custom Report'), - ] - - OUTPUT_FORMATS = [ - ('PDF', 'PDF Document'), - ('EXCEL', 'Excel Spreadsheet'), - ('CSV', 'CSV File'), - ('JSON', 'JSON Data'), - ('HTML', 'HTML Page'), - ('EMAIL', 'Email Report'), - ] - - SCHEDULE_TYPES = [ - ('MANUAL', 'Manual Execution'), - ('DAILY', 'Daily'), - ('WEEKLY', 'Weekly'), - ('MONTHLY', 'Monthly'), - ('QUARTERLY', 'Quarterly'), - ('YEARLY', 'Yearly'), - ('CUSTOM', 'Custom Schedule'), - ] + + class ReportType(models.TextChoices): + OPERATIONAL = 'OPERATIONAL', 'Operational Report' + FINANCIAL = 'FINANCIAL', 'Financial Report' + CLINICAL = 'CLINICAL', 'Clinical Report' + QUALITY = 'QUALITY', 'Quality Report' + COMPLIANCE = 'COMPLIANCE', 'Compliance Report' + PERFORMANCE = 'PERFORMANCE', 'Performance Report' + CUSTOM = 'CUSTOM', 'Custom Report' + + class OutputFormat(models.TextChoices): + PDF = 'PDF', 'PDF Document' + EXCEL = 'EXCEL', 'Excel Spreadsheet' + CSV = 'CSV', 'CSV File' + JSON = 'JSON', 'JSON Data' + HTML = 'HTML', 'HTML Page' + EMAIL = 'EMAIL', 'Email Report' + + class ScheduleType(models.TextChoices): + MANUAL = 'MANUAL', 'Manual Execution' + DAILY = 'DAILY', 'Daily' + WEEKLY = 'WEEKLY', 'Weekly' + MONTHLY = 'MONTHLY', 'Monthly' + QUARTERLY = 'QUARTERLY', 'Quarterly' + YEARLY = 'YEARLY', 'Yearly' + CUSTOM = 'CUSTOM', 'Custom Schedule' report_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='reports') @@ -265,18 +261,18 @@ class Report(models.Model): # Report configuration name = models.CharField(max_length=200) description = models.TextField(blank=True) - report_type = models.CharField(max_length=20, choices=REPORT_TYPES) + report_type = models.CharField(max_length=20, choices=ReportType.choices) # Data source data_source = models.ForeignKey(DataSource, on_delete=models.CASCADE, related_name='reports') query_config = models.JSONField(default=dict, help_text="Query configuration for report") # Output configuration - output_format = models.CharField(max_length=20, choices=OUTPUT_FORMATS) + output_format = models.CharField(max_length=20, choices=OutputFormat.choices) template_config = models.JSONField(default=dict, help_text="Report template configuration") # Scheduling - schedule_type = models.CharField(max_length=20, choices=SCHEDULE_TYPES, default='MANUAL') + schedule_type = models.CharField(max_length=20, choices=ScheduleType.choices, default=ScheduleType.MANUAL) schedule_config = models.JSONField(default=dict, help_text="Schedule configuration") next_execution = models.DateTimeField(null=True, blank=True) @@ -310,23 +306,24 @@ class ReportExecution(models.Model): """ Report execution model for tracking report runs. """ - EXECUTION_STATUS = [ - ('PENDING', 'Pending'), - ('RUNNING', 'Running'), - ('COMPLETED', 'Completed'), - ('FAILED', 'Failed'), - ('CANCELLED', 'Cancelled'), - ] + + class ExecutionStatus(models.TextChoices): + PENDING = 'PENDING', 'Pending' + RUNNING = 'RUNNING', 'Running' + COMPLETED = 'COMPLETED', 'Completed' + FAILED = 'FAILED', 'Failed' + CANCELLED = 'CANCELLED', 'Cancelled' + + class ExecutionType(models.TextChoices): + MANUAL = 'MANUAL', 'Manual' + SCHEDULED = 'SCHEDULED', 'Scheduled' + API = 'API', 'API Triggered' execution_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) report = models.ForeignKey(Report, on_delete=models.CASCADE, related_name='executions') # Execution details - execution_type = models.CharField(max_length=20, choices=[ - ('MANUAL', 'Manual'), - ('SCHEDULED', 'Scheduled'), - ('API', 'API Triggered'), - ], default='MANUAL') + execution_type = models.CharField(max_length=20, choices=ExecutionType.choices, default=ExecutionType.MANUAL) # Timing started_at = models.DateTimeField(auto_now_add=True) @@ -334,7 +331,7 @@ class ReportExecution(models.Model): duration_seconds = models.PositiveIntegerField(null=True, blank=True) # Status and results - status = models.CharField(max_length=20, choices=EXECUTION_STATUS, default='PENDING') + status = models.CharField(max_length=20, choices=ExecutionStatus.choices, default=ExecutionStatus.PENDING) error_message = models.TextField(blank=True) # Output @@ -370,26 +367,25 @@ class MetricDefinition(models.Model): """ Metric definition model for KPI and performance metrics. """ - METRIC_TYPES = [ - ('COUNT', 'Count'), - ('SUM', 'Sum'), - ('AVERAGE', 'Average'), - ('PERCENTAGE', 'Percentage'), - ('RATIO', 'Ratio'), - ('RATE', 'Rate'), - ('DURATION', 'Duration'), - ('CUSTOM', 'Custom Calculation'), - ] - - AGGREGATION_PERIODS = [ - ('REAL_TIME', 'Real-time'), - ('HOURLY', 'Hourly'), - ('DAILY', 'Daily'), - ('WEEKLY', 'Weekly'), - ('MONTHLY', 'Monthly'), - ('QUARTERLY', 'Quarterly'), - ('YEARLY', 'Yearly'), - ] + + class MetricType(models.TextChoices): + COUNT = 'COUNT', 'Count' + SUM = 'SUM', 'Sum' + AVERAGE = 'AVERAGE', 'Average' + PERCENTAGE = 'PERCENTAGE', 'Percentage' + RATIO = 'RATIO', 'Ratio' + RATE = 'RATE', 'Rate' + DURATION = 'DURATION', 'Duration' + CUSTOM = 'CUSTOM', 'Custom Calculation' + + class AggregationPeriod(models.TextChoices): + REAL_TIME = 'REAL_TIME', 'Real-time' + HOURLY = 'HOURLY', 'Hourly' + DAILY = 'DAILY', 'Daily' + WEEKLY = 'WEEKLY', 'Weekly' + MONTHLY = 'MONTHLY', 'Monthly' + QUARTERLY = 'QUARTERLY', 'Quarterly' + YEARLY = 'YEARLY', 'Yearly' metric_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='metric_definitions') @@ -397,14 +393,14 @@ class MetricDefinition(models.Model): # Metric configuration name = models.CharField(max_length=200) description = models.TextField(blank=True) - metric_type = models.CharField(max_length=20, choices=METRIC_TYPES) + metric_type = models.CharField(max_length=20, choices=MetricType.choices) # Data source data_source = models.ForeignKey(DataSource, on_delete=models.CASCADE, related_name='metrics') calculation_config = models.JSONField(default=dict, help_text="Metric calculation configuration") # Aggregation - aggregation_period = models.CharField(max_length=20, choices=AGGREGATION_PERIODS) + aggregation_period = models.CharField(max_length=20, choices=AggregationPeriod.choices) aggregation_config = models.JSONField(default=dict, help_text="Aggregation configuration") # Thresholds and targets diff --git a/analytics_data.py b/analytics_data.py index f6eabb4c..f7868637 100644 --- a/analytics_data.py +++ b/analytics_data.py @@ -32,7 +32,7 @@ SAUDI_DEPARTMENTS = [ def generate_dashboards(tenants, users): dashboards = [] - types = [dt[0] for dt in Dashboard.DASHBOARD_TYPES] + types = [dt[0] for dt in Dashboard.DashboardType.choices] for tenant in tenants: for _ in range(random.randint(2, 5)): creator = random.choice(users) diff --git a/appointments/__pycache__/models.cpython-312.pyc b/appointments/__pycache__/models.cpython-312.pyc index e7385658..5843f3d4 100644 Binary files a/appointments/__pycache__/models.cpython-312.pyc and b/appointments/__pycache__/models.cpython-312.pyc differ diff --git a/appointments/__pycache__/views.cpython-312.pyc b/appointments/__pycache__/views.cpython-312.pyc index 41f6326c..b4caa41a 100644 Binary files a/appointments/__pycache__/views.cpython-312.pyc and b/appointments/__pycache__/views.cpython-312.pyc differ diff --git a/appointments/migrations/0001_initial.py b/appointments/migrations/0001_initial.py index f2b246a9..8067daa9 100644 --- a/appointments/migrations/0001_initial.py +++ b/appointments/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-09-19 10:58 +# Generated by Django 5.2.6 on 2025-09-26 18:33 import django.core.validators import django.db.models.deletion @@ -465,6 +465,7 @@ class Migration(migrations.Migration): choices=[ ("SCHEDULED", "Scheduled"), ("READY", "Ready to Start"), + ("WAITING", "Waiting"), ("IN_PROGRESS", "In Progress"), ("COMPLETED", "Completed"), ("CANCELLED", "Cancelled"), diff --git a/appointments/migrations/0002_initial.py b/appointments/migrations/0002_initial.py index e4559393..8f7f2d9c 100644 --- a/appointments/migrations/0002_initial.py +++ b/appointments/migrations/0002_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-09-19 10:58 +# Generated by Django 5.2.6 on 2025-09-26 18:33 import django.db.models.deletion from django.conf import settings diff --git a/appointments/migrations/__pycache__/0001_initial.cpython-312.pyc b/appointments/migrations/__pycache__/0001_initial.cpython-312.pyc index 2d786566..0339b5f2 100644 Binary files a/appointments/migrations/__pycache__/0001_initial.cpython-312.pyc and b/appointments/migrations/__pycache__/0001_initial.cpython-312.pyc differ diff --git a/appointments/migrations/__pycache__/0002_initial.cpython-312.pyc b/appointments/migrations/__pycache__/0002_initial.cpython-312.pyc index 5015e6bf..2b9f998d 100644 Binary files a/appointments/migrations/__pycache__/0002_initial.cpython-312.pyc and b/appointments/migrations/__pycache__/0002_initial.cpython-312.pyc differ diff --git a/appointments/mixins.py b/appointments/mixins.py index 1adbc917..95c661d2 100644 --- a/appointments/mixins.py +++ b/appointments/mixins.py @@ -16,7 +16,7 @@ class PatientContextMixin: patient_query_param = "patient" def get_patient(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant # 1) URL kwarg patient_id = self.kwargs.get(self.patient_kwarg_name) diff --git a/appointments/models.py b/appointments/models.py index ade3367e..3dc4ecf9 100644 --- a/appointments/models.py +++ b/appointments/models.py @@ -894,6 +894,7 @@ class TelemedicineSession(models.Model): class SessionStatus(models.TextChoices): SCHEDULED = 'SCHEDULED', 'Scheduled' READY = 'READY', 'Ready to Start' + WAITING = 'WAITING', 'Waiting' IN_PROGRESS = 'IN_PROGRESS', 'In Progress' COMPLETED = 'COMPLETED', 'Completed' CANCELLED = 'CANCELLED', 'Cancelled' diff --git a/appointments/templates/appointments/requests/appointment_form.html b/appointments/templates/appointments/requests/appointment_form.html index 1a2bb797..5d55778f 100644 --- a/appointments/templates/appointments/requests/appointment_form.html +++ b/appointments/templates/appointments/requests/appointment_form.html @@ -7,16 +7,12 @@ {% endblock %} {% block content %}
-
+
- -

- {% if object %}Edit Appointment{% else %}Schedule New Appointment{% endif %} +

+ {% if object %}EditAppointment{% else %}NewAppointment{% endif %}

+

Schedule appointments, manage queues, and track your progress.

-
-
-

- - Appointment Details -

+ +
+
+

+ Appointment Details +

+
+ + + +
-
+
+
{% csrf_token %}
@@ -249,14 +251,19 @@
-
-
-
- - Doctor Schedule -
+
+
+

+ Doctor Schedule +

+
+ + + +
-
+
+
@@ -267,14 +274,19 @@
-
-
-
- - Available Slots -
+
+
+

+ Available Slots +

+
+ + + +
-
+
+
@@ -285,14 +297,20 @@
-
-
-
- - Guidelines -
+ +
+
+

+ Guidelines +

+
+ + + +
-
+
+

diff --git a/appointments/templates/appointments/requests/appointment_list.html b/appointments/templates/appointments/requests/appointment_list.html index 01b1c463..8ffbb386 100644 --- a/appointments/templates/appointments/requests/appointment_list.html +++ b/appointments/templates/appointments/requests/appointment_list.html @@ -109,9 +109,9 @@

-
+
- + diff --git a/appointments/templates/appointments/telemedicine/telemedicine_session_detail.html b/appointments/templates/appointments/telemedicine/telemedicine_session_detail.html index c4f0284e..83a6c82d 100644 --- a/appointments/templates/appointments/telemedicine/telemedicine_session_detail.html +++ b/appointments/templates/appointments/telemedicine/telemedicine_session_detail.html @@ -50,16 +50,20 @@ @@ -90,7 +94,11 @@ {% endif %} + {% if session.duration_minutes %} + {% else %} + + {% endif %}
Date & Time PatientStatus: {% if session.status == 'SCHEDULED' %} - Scheduled + + {% elif session.status == 'READY' %} + {% elif session.status == 'WAITING' %} - Waiting + {% elif session.status == 'IN_PROGRESS' %} - In Progress + {% elif session.status == 'COMPLETED' %} - Completed + {% elif session.status == 'CANCELLED' %} - Cancelled + {% endif %} + {{ session.get_status_display }} +
Duration:{{ session.duration_minutes }} minutesWill be calculated after starting
@@ -290,11 +298,11 @@ - + - + diff --git a/appointments/templates/appointments/waiting_list/waiting_list.html b/appointments/templates/appointments/waiting_list/waiting_list.html index 63c5d741..4256278d 100644 --- a/appointments/templates/appointments/waiting_list/waiting_list.html +++ b/appointments/templates/appointments/waiting_list/waiting_list.html @@ -4,25 +4,35 @@ {% block title %}Patient Waiting List Management{% endblock %} {% block css %} - - - {% endblock %} @@ -201,22 +211,22 @@
-
Scheduled Date:{{ session.appointment.appointment_date|date:"M d, Y" }}{{ session.appointment.preferred_date|date:"M d, Y" }}
Scheduled Time:{{ session.appointment.appointment_time|time:"H:i" }}{{ session.appointment.preferred_time|time:"H:i" }}
Duration:
- +
+ - - - - - - - - - - - + + + + + + + + + + @@ -398,56 +408,26 @@ {% endblock %} {% block js %} - - - - - - - - - {% endblock %} - diff --git a/appointments/views.py b/appointments/views.py index afe812a3..4d2b4bf9 100644 --- a/appointments/views.py +++ b/appointments/views.py @@ -91,7 +91,7 @@ class AppointmentRequestListView(LoginRequiredMixin, ListView): paginate_by = 25 def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return AppointmentRequest.objects.none() @@ -164,7 +164,7 @@ class AppointmentRequestDetailView(LoginRequiredMixin, DetailView): context_object_name = 'appointment' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return AppointmentRequest.objects.none() return AppointmentRequest.objects.filter(tenant=tenant) @@ -197,7 +197,7 @@ class AppointmentRequestCreateView(LoginRequiredMixin, CreateView): def dispatch(self, request, *args, **kwargs): # Ensure tenant exists (if you follow multi-tenant pattern) - self.tenant = getattr(request, "tenant", None) + self.tenant = self.request.user.tenant if not self.tenant: return JsonResponse({"error": "No tenant found"}, status=400) @@ -255,7 +255,7 @@ class AppointmentRequestUpdateView(LoginRequiredMixin, UpdateView): permission_required = 'appointments.change_appointmentrequest' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return AppointmentRequest.objects.none() return AppointmentRequest.objects.filter(tenant=tenant) @@ -335,7 +335,7 @@ class SlotAvailabilityListView(LoginRequiredMixin, ListView): paginate_by = 25 def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return SlotAvailability.objects.none() @@ -380,7 +380,7 @@ class SlotAvailabilityDetailView(LoginRequiredMixin, DetailView): context_object_name = 'slot' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return SlotAvailability.objects.none() return SlotAvailability.objects.filter(provider__tenant=tenant) @@ -420,7 +420,7 @@ class SlotAvailabilityCreateView(LoginRequiredMixin, CreateView): # Log slot creation AuditLogger.log_event( - tenant=getattr(self.request, 'tenant', None), + tenant=self.request.user.tenant, event_type='CREATE', event_category='APPOINTMENT_MANAGEMENT', action='Create Slot Availability', @@ -444,7 +444,7 @@ class SlotAvailabilityUpdateView(LoginRequiredMixin, UpdateView): permission_required = 'appointments.change_slotavailability' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return SlotAvailability.objects.none() return SlotAvailability.objects.filter(provider__tenant=tenant) @@ -462,7 +462,7 @@ class SlotAvailabilityUpdateView(LoginRequiredMixin, UpdateView): # Log slot update AuditLogger.log_event( - tenant=getattr(self.request, 'tenant', None), + tenant=self.request.user.tenant, event_type='UPDATE', event_category='APPOINTMENT_MANAGEMENT', action='Update Slot Availability', @@ -486,7 +486,7 @@ class SlotAvailabilityDeleteView(LoginRequiredMixin, DeleteView): success_url = reverse_lazy('appointments:slot_availability_list') def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return SlotAvailability.objects.none() return SlotAvailability.objects.filter(provider__tenant=tenant) @@ -536,7 +536,7 @@ class WaitingQueueListView(LoginRequiredMixin, ListView): paginate_by = 25 def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return WaitingQueue.objects.none() @@ -586,7 +586,7 @@ class WaitingQueueDetailView(LoginRequiredMixin, DetailView): context_object_name = 'queue' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return WaitingQueue.objects.none() return WaitingQueue.objects.filter(tenant=tenant) @@ -633,7 +633,7 @@ class WaitingQueueCreateView(LoginRequiredMixin, CreateView): def form_valid(self, form): # Set tenant - form.instance.tenant = getattr(self.request, 'tenant', None) + form.instance.tenant = self.request.user.tenant response = super().form_valid(form) # Log queue creation @@ -662,7 +662,7 @@ class WaitingQueueUpdateView(LoginRequiredMixin, UpdateView): permission_required = 'appointments.change_waitingqueue' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return WaitingQueue.objects.none() return WaitingQueue.objects.filter(tenant=tenant) @@ -751,7 +751,7 @@ class QueueEntryListView(LoginRequiredMixin, ListView): paginate_by = 25 def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return QueueEntry.objects.none() @@ -798,7 +798,7 @@ class QueueEntryDetailView(LoginRequiredMixin, DetailView): context_object_name = 'entry' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return QueueEntry.objects.none() return QueueEntry.objects.filter(queue__tenant=tenant) @@ -830,7 +830,7 @@ class QueueEntryCreateView(LoginRequiredMixin, CreateView): # Log queue entry creation AuditLogger.log_event( - tenant=getattr(self.request, 'tenant', None), + tenant=self.request.user.tenant, event_type='CREATE', event_category='APPOINTMENT_MANAGEMENT', action='Create Queue Entry', @@ -854,7 +854,7 @@ class QueueEntryUpdateView(LoginRequiredMixin, UpdateView): permission_required = 'appointments.change_queueentry' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return QueueEntry.objects.none() return QueueEntry.objects.filter(queue__tenant=tenant) @@ -872,7 +872,7 @@ class QueueEntryUpdateView(LoginRequiredMixin, UpdateView): # Log queue entry update AuditLogger.log_event( - tenant=getattr(self.request, 'tenant', None), + tenant=self.request.user.tenant, event_type='UPDATE', event_category='APPOINTMENT_MANAGEMENT', action='Update Queue Entry', @@ -896,7 +896,7 @@ class TelemedicineSessionListView(LoginRequiredMixin, ListView): paginate_by = 25 def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return TelemedicineSession.objects.none() @@ -923,7 +923,7 @@ class TelemedicineSessionListView(LoginRequiredMixin, ListView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if tenant: context.update({ @@ -943,7 +943,7 @@ class TelemedicineSessionDetailView(LoginRequiredMixin, DetailView): context_object_name = 'session' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return TelemedicineSession.objects.none() return TelemedicineSession.objects.filter(appointment__tenant=tenant) @@ -969,7 +969,7 @@ class TelemedicineSessionCreateView(LoginRequiredMixin, CreateView): # Log session creation AuditLogger.log_event( - tenant=getattr(self.request, 'tenant', None), + tenant=self.request.user.tenant, event_type='CREATE', event_category='APPOINTMENT_MANAGEMENT', action='Create Telemedicine Session', @@ -993,7 +993,7 @@ class TelemedicineSessionUpdateView(LoginRequiredMixin, UpdateView): permission_required = 'appointments.change_telemedicinesession' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return TelemedicineSession.objects.none() return TelemedicineSession.objects.filter(appointment__tenant=tenant) @@ -1011,7 +1011,7 @@ class TelemedicineSessionUpdateView(LoginRequiredMixin, UpdateView): # Log session update AuditLogger.log_event( - tenant=getattr(self.request, 'tenant', None), + tenant=self.request.user.tenant, event_type='UPDATE', event_category='APPOINTMENT_MANAGEMENT', action='Update Telemedicine Session', @@ -1035,7 +1035,7 @@ class AppointmentTemplateListView(LoginRequiredMixin, ListView): paginate_by = 25 def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return AppointmentTemplate.objects.none() @@ -1072,7 +1072,7 @@ class AppointmentTemplateListView(LoginRequiredMixin, ListView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if tenant: context.update({ @@ -1098,7 +1098,7 @@ class AppointmentTemplateDetailView(LoginRequiredMixin, DetailView): context_object_name = 'template' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return AppointmentTemplate.objects.none() return AppointmentTemplate.objects.filter(tenant=tenant) @@ -1121,7 +1121,7 @@ class AppointmentTemplateCreateView(LoginRequiredMixin, CreateView): def form_valid(self, form): # Set tenant - form.instance.tenant = getattr(self.request, 'tenant', None) + form.instance.tenant = self.request.user.tenant response = super().form_valid(form) # Log template creation @@ -1150,7 +1150,7 @@ class AppointmentTemplateUpdateView(LoginRequiredMixin, UpdateView): permission_required = 'appointments.change_appointmenttemplate' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return AppointmentTemplate.objects.none() return AppointmentTemplate.objects.filter(tenant=tenant) @@ -1192,7 +1192,7 @@ class AppointmentTemplateDeleteView(LoginRequiredMixin, DeleteView): success_url = reverse_lazy('appointments:appointment_template_list') def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return AppointmentTemplate.objects.none() return AppointmentTemplate.objects.filter(tenant=tenant) @@ -1333,11 +1333,11 @@ class WaitingListCreateView(LoginRequiredMixin, CreateView): def get_form_kwargs(self): kwargs = super().get_form_kwargs() - kwargs['tenant'] = getattr(self.request.user, 'tenant', None) + kwargs['tenant'] = self.request.user.tenant return kwargs def form_valid(self, form): - form.instance.tenant = getattr(self.request.user, 'tenant', None) + form.instance.tenant = self.request.user.tenant form.instance.created_by = self.request.user response = super().form_valid(form) diff --git a/appointments_data.py b/appointments_data.py index 74081000..a4e32a83 100644 --- a/appointments_data.py +++ b/appointments_data.py @@ -5,13 +5,15 @@ import django os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'hospital_management.settings') django.setup() +# Django setup will be handled by manage.py when running this script +# Import Django-related modules after Django is set up import random from datetime import datetime, date, time, timedelta from django.utils import timezone as django_timezone from django.contrib.auth import get_user_model from appointments.models import ( AppointmentRequest, SlotAvailability, WaitingQueue, QueueEntry, - TelemedicineSession, AppointmentTemplate + TelemedicineSession, AppointmentTemplate, WaitingList, WaitingListContactLog ) from patients.models import PatientProfile from core.models import Tenant @@ -820,6 +822,403 @@ def create_telemedicine_sessions(appointments): return sessions +def create_waiting_list_entries(tenants, num_entries_per_tenant=100): + """Create waiting list entries with realistic clinical scenarios""" + waiting_list_entries = [] + + # Get patients and providers + patients = list(PatientProfile.objects.filter(tenant__in=tenants)) + providers = get_providers_for_tenant(tenants) + departments = list(Department.objects.filter(tenant__in=tenants)) + + if not patients: + print("No patients found. Skipping waiting list creation.") + return waiting_list_entries + + if not departments: + print("No departments found. Skipping waiting list creation.") + return waiting_list_entries + + # Clinical scenarios for waiting list entries + clinical_scenarios = [ + { + 'specialty': 'CARDIOLOGY', + 'appointment_type': 'CONSULTATION', + 'clinical_indication': 'Chest pain evaluation and cardiac assessment', + 'diagnosis_codes': ['R07.9', 'I25.10'], + 'priority': 'URGENT', + 'urgency_score': random.randint(6, 8), + 'referral_urgency': 'URGENT' + }, + { + 'specialty': 'ORTHOPEDICS', + 'appointment_type': 'FOLLOW_UP', + 'clinical_indication': 'Post-operative knee replacement follow-up', + 'diagnosis_codes': ['Z47.1', 'M17.9'], + 'priority': 'ROUTINE', + 'urgency_score': random.randint(2, 4), + 'referral_urgency': 'ROUTINE' + }, + { + 'specialty': 'PEDIATRICS', + 'appointment_type': 'SCREENING', + 'clinical_indication': 'Well-child visit and developmental assessment', + 'diagnosis_codes': ['Z00.129'], + 'priority': 'ROUTINE', + 'urgency_score': random.randint(1, 3), + 'referral_urgency': 'ROUTINE' + }, + { + 'specialty': 'DERMATOLOGY', + 'appointment_type': 'PROCEDURE', + 'clinical_indication': 'Suspicious skin lesion requiring biopsy', + 'diagnosis_codes': ['D48.5', 'L98.9'], + 'priority': 'URGENT', + 'urgency_score': random.randint(5, 7), + 'referral_urgency': 'URGENT' + }, + { + 'specialty': 'GYNECOLOGY', + 'appointment_type': 'CONSULTATION', + 'clinical_indication': 'Abnormal uterine bleeding evaluation', + 'diagnosis_codes': ['N93.9', 'N85.00'], + 'priority': 'URGENT', + 'urgency_score': random.randint(6, 8), + 'referral_urgency': 'URGENT' + }, + { + 'specialty': 'NEUROLOGY', + 'appointment_type': 'DIAGNOSTIC', + 'clinical_indication': 'Headache evaluation and neurological assessment', + 'diagnosis_codes': ['R51', 'G44.209'], + 'priority': 'STAT', + 'urgency_score': random.randint(7, 9), + 'referral_urgency': 'STAT' + }, + { + 'specialty': 'ENDOCRINOLOGY', + 'appointment_type': 'FOLLOW_UP', + 'clinical_indication': 'Diabetes management and glycemic control', + 'diagnosis_codes': ['E11.9', 'Z79.4'], + 'priority': 'ROUTINE', + 'urgency_score': random.randint(2, 5), + 'referral_urgency': 'ROUTINE' + }, + { + 'specialty': 'SURGERY', + 'appointment_type': 'CONSULTATION', + 'clinical_indication': 'Gallbladder disease requiring surgical evaluation', + 'diagnosis_codes': ['K80.20', 'R10.11'], + 'priority': 'URGENT', + 'urgency_score': random.randint(5, 8), + 'referral_urgency': 'URGENT' + }, + { + 'specialty': 'PSYCHIATRY', + 'appointment_type': 'THERAPY', + 'clinical_indication': 'Depression and anxiety management', + 'diagnosis_codes': ['F32.9', 'F41.9'], + 'priority': 'ROUTINE', + 'urgency_score': random.randint(3, 6), + 'referral_urgency': 'ROUTINE' + }, + { + 'specialty': 'UROLOGY', + 'appointment_type': 'DIAGNOSTIC', + 'clinical_indication': 'Elevated PSA and prostate evaluation', + 'diagnosis_codes': ['R97.20', 'N40.0'], + 'priority': 'URGENT', + 'urgency_score': random.randint(5, 7), + 'referral_urgency': 'URGENT' + } + ] + + for tenant in tenants: + tenant_patients = [p for p in patients if p.tenant == tenant] + tenant_providers = [p for p in providers if p.tenant == tenant] + tenant_departments = [d for d in departments if d.tenant == tenant] + + if not tenant_patients or not tenant_departments: + continue + + for _ in range(num_entries_per_tenant): + patient = random.choice(tenant_patients) + scenario = random.choice(clinical_scenarios) + + # Select appropriate department + suitable_departments = [ + d for d in tenant_departments + if hasattr(d, 'department_type') and d.department_type == 'CLINICAL' + ] + department = random.choice(suitable_departments) if suitable_departments else random.choice(tenant_departments) + + # Provider assignment (optional, 70% have preferred provider) + provider = None + if random.random() < 0.7 and tenant_providers: + provider = random.choice(tenant_providers) + + # Status distribution (weighted towards active entries) + status_weights = { + 'ACTIVE': 60, + 'CONTACTED': 15, + 'OFFERED': 10, + 'SCHEDULED': 8, + 'CANCELLED': 4, + 'EXPIRED': 2, + 'TRANSFERRED': 1 + } + status = random.choices( + list(status_weights.keys()), + weights=list(status_weights.values()) + )[0] + + # Contact information + contact_method = random.choice(['PHONE', 'EMAIL', 'SMS']) + contact_phone = f"+966{random.randint(500000000, 599999999)}" if contact_method == 'PHONE' else None + contact_email = f"{patient.email}" if contact_method == 'EMAIL' else None + + # Timing and scheduling preferences + created_at = django_timezone.now() - timedelta(days=random.randint(1, 90)) + preferred_date = created_at.date() + timedelta(days=random.randint(7, 60)) if random.random() < 0.8 else None + preferred_time = time(random.randint(8, 16), random.choice([0, 30])) if preferred_date else None + + # Contact history + contact_attempts = 0 + last_contacted = None + appointments_offered = 0 + appointments_declined = 0 + last_offer_date = None + + if status in ['CONTACTED', 'OFFERED', 'SCHEDULED']: + contact_attempts = random.randint(1, 3) + last_contacted = created_at + timedelta(days=random.randint(1, 30)) + + if status in ['OFFERED', 'SCHEDULED']: + appointments_offered = random.randint(1, 2) + last_offer_date = last_contacted + timedelta(days=random.randint(1, 7)) + + if random.random() < 0.3: # 30% decline offers + appointments_declined = random.randint(1, appointments_offered) + + # Authorization requirements (for procedures/surgery) + authorization_required = scenario['appointment_type'] in ['PROCEDURE', 'SURGERY'] + authorization_status = 'NOT_REQUIRED' + authorization_number = None + + if authorization_required: + authorization_status = random.choice(['PENDING', 'APPROVED', 'DENIED']) + if authorization_status == 'APPROVED': + authorization_number = f"AUTH-{random.randint(100000, 999999)}" + + # Special requirements + requires_interpreter = random.random() < 0.05 # 5% need interpreter + interpreter_language = random.choice(['Arabic', 'English', 'Urdu', 'Hindi']) if requires_interpreter else None + + accessibility_requirements = None + if random.random() < 0.1: # 10% have accessibility needs + accessibility_requirements = random.choice([ + 'Wheelchair accessible room required', + 'Sign language interpreter needed', + 'Hearing assistance devices needed', + 'Visual assistance required' + ]) + + transportation_needed = random.random() < 0.15 # 15% need transportation + + # Referral information + referring_provider = f"Dr. {random.choice(['Ahmed', 'Fatima', 'Mohammed', 'Sara'])} Al-{random.choice(['Rashid', 'Ghamdi', 'Otaibi', 'Harbi'])}" + referral_date = created_at - timedelta(days=random.randint(1, 14)) + + # Notes + notes = random.choice([ + None, + f"Patient prefers {scenario['specialty'].replace('_', ' ').title().lower()} specialist", + "Patient has multiple comorbidities", + "Requires extended appointment time", + "Patient has transportation limitations", + "Family member will accompany patient" + ]) + + try: + waiting_entry = WaitingList.objects.create( + tenant=tenant, + patient=patient, + provider=provider, + department=department, + appointment_type=scenario['appointment_type'], + specialty=scenario['specialty'], + priority=scenario['priority'], + urgency_score=scenario['urgency_score'], + clinical_indication=scenario['clinical_indication'], + diagnosis_codes=scenario['diagnosis_codes'], + preferred_date=preferred_date, + preferred_time=preferred_time, + flexible_scheduling=random.choice([True, False]), + earliest_acceptable_date=preferred_date - timedelta(days=random.randint(0, 7)) if preferred_date else None, + latest_acceptable_date=preferred_date + timedelta(days=random.randint(7, 21)) if preferred_date else None, + acceptable_days=[0, 1, 2, 3, 4] if random.random() < 0.8 else [], # Monday-Friday + acceptable_times=['morning', 'afternoon'] if random.random() < 0.7 else [], + contact_method=contact_method, + contact_phone=contact_phone, + contact_email=contact_email, + status=status, + position=None, # Will be calculated later + estimated_wait_time=scenario['urgency_score'] * 7, # Days based on urgency + last_contacted=last_contacted, + contact_attempts=contact_attempts, + max_contact_attempts=3, + appointments_offered=appointments_offered, + appointments_declined=appointments_declined, + last_offer_date=last_offer_date, + requires_interpreter=requires_interpreter, + interpreter_language=interpreter_language, + accessibility_requirements=accessibility_requirements, + transportation_needed=transportation_needed, + insurance_verified=random.choice([True, False]), + authorization_required=authorization_required, + authorization_status=authorization_status, + authorization_number=authorization_number, + referring_provider=referring_provider, + referral_date=referral_date, + referral_urgency=scenario['referral_urgency'], + created_at=created_at, + updated_at=django_timezone.now() - timedelta(days=random.randint(0, 7)), + notes=notes + ) + waiting_list_entries.append(waiting_entry) + + except Exception as e: + print(f"Error creating waiting list entry for {patient.get_full_name()}: {e}") + continue + + # Update positions for active entries + for entry in waiting_list_entries: + if entry.status == 'ACTIVE': + try: + entry.position = entry.calculate_position() + entry.estimated_wait_time = entry.estimate_wait_time() + entry.save(update_fields=['position', 'estimated_wait_time']) + except Exception as e: + print(f"Error updating position for waiting list entry {entry.waiting_list_id}: {e}") + + print(f"Created {len(waiting_list_entries)} waiting list entries") + return waiting_list_entries + + +def create_waiting_list_contact_logs(waiting_list_entries): + """Create contact logs for waiting list entries""" + contact_logs = [] + + # Only create logs for entries that have been contacted + contacted_entries = [entry for entry in waiting_list_entries if entry.contact_attempts > 0] + + for entry in contacted_entries: + # Create logs for each contact attempt + for attempt_num in range(1, entry.contact_attempts + 1): + contact_date = entry.last_contacted - timedelta(days=random.randint(0, 7) * (entry.contact_attempts - attempt_num)) + + # Contact method based on entry preferences + contact_method = entry.contact_method + if contact_method == 'PHONE': + contact_method = 'PHONE' + elif contact_method == 'EMAIL': + contact_method = 'EMAIL' + else: + contact_method = 'SMS' + + # Contact outcomes + possible_outcomes = ['SUCCESSFUL', 'NO_ANSWER', 'BUSY', 'VOICEMAIL', 'EMAIL_SENT', 'SMS_SENT'] + if attempt_num == entry.contact_attempts: + # Last attempt more likely to be successful + outcome_weights = [40, 20, 10, 10, 10, 10] + else: + outcome_weights = [20, 30, 15, 15, 10, 10] + + contact_outcome = random.choices(possible_outcomes, weights=outcome_weights)[0] + + # Patient responses (only for successful contacts) + patient_response = None + appointment_offered = False + offered_date = None + offered_time = None + + if contact_outcome == 'SUCCESSFUL': + response_options = ['ACCEPTED', 'DECLINED', 'REQUESTED_DIFFERENT', 'WILL_CALL_BACK', 'NO_LONGER_NEEDED'] + response_weights = [30, 20, 25, 15, 10] + patient_response = random.choices(response_options, weights=response_weights)[0] + + # Offer appointment if appropriate + if patient_response in ['ACCEPTED', 'REQUESTED_DIFFERENT'] and random.random() < 0.7: + appointment_offered = True + offered_date = contact_date.date() + timedelta(days=random.randint(7, 21)) + offered_time = time(random.randint(8, 16), random.choice([0, 30])) + + # Notes based on outcome + notes_templates = { + 'SUCCESSFUL': [ + f"Spoke with patient regarding {entry.appointment_type.lower()} appointment", + "Patient confirmed interest in scheduling", + "Discussed clinical urgency and wait times", + "Patient requested specific time preferences" + ], + 'NO_ANSWER': [ + "No answer after multiple rings", + "Call went to voicemail", + "Patient did not pick up" + ], + 'BUSY': [ + "Line was busy", + "Patient indicated they were busy", + "Call disconnected due to network issues" + ], + 'VOICEMAIL': [ + "Left detailed voicemail about appointment availability", + "Voicemail left with callback instructions", + "Message left explaining wait list status" + ], + 'EMAIL_SENT': [ + "Email sent with appointment options", + "Detailed email with scheduling information", + "Follow-up email sent as requested" + ], + 'SMS_SENT': [ + "SMS sent with appointment reminder", + "Text message with scheduling options", + "SMS follow-up sent" + ] + } + + notes = random.choice(notes_templates.get(contact_outcome, ["Contact attempt made"])) + + # Next contact date for unsuccessful attempts + next_contact_date = None + if contact_outcome != 'SUCCESSFUL' and attempt_num < entry.max_contact_attempts: + next_contact_date = contact_date.date() + timedelta(days=random.randint(3, 7)) + + try: + contact_log = WaitingListContactLog.objects.create( + waiting_list_entry=entry, + contact_date=contact_date, + contact_method=contact_method, + contact_outcome=contact_outcome, + appointment_offered=appointment_offered, + offered_date=offered_date, + offered_time=offered_time, + patient_response=patient_response, + notes=notes, + next_contact_date=next_contact_date, + contacted_by=random.choice([entry.provider, None]) if entry.provider else None + ) + contact_logs.append(contact_log) + + except Exception as e: + print(f"Error creating contact log for waiting list entry {entry.waiting_list_id}: {e}") + continue + + print(f"Created {len(contact_logs)} waiting list contact logs") + return contact_logs + + def create_queue_entries(queues, appointments): """Create queue entries for appointments""" entries = [] @@ -942,8 +1341,16 @@ def main(): print("\n5. Creating Telemedicine Sessions...") sessions = create_telemedicine_sessions(appointments) + # Create waiting list entries + print("\n6. Creating Waiting List Entries...") + waiting_list_entries = create_waiting_list_entries(tenants, 100) + + # Create waiting list contact logs + print("\n7. Creating Waiting List Contact Logs...") + contact_logs = create_waiting_list_contact_logs(waiting_list_entries) + # Create queue entries - print("\n6. Creating Queue Entries...") + print("\n8. Creating Queue Entries...") entries = create_queue_entries(queues, appointments) print(f"\n✅ Saudi Healthcare Appointments Data Generation Complete!") @@ -953,6 +1360,8 @@ def main(): print(f" - Availability Slots: {len(slots)}") print(f" - Appointment Requests: {len(appointments)}") print(f" - Telemedicine Sessions: {len(sessions)}") + print(f" - Waiting List Entries: {len(waiting_list_entries)}") + print(f" - Waiting List Contact Logs: {len(contact_logs)}") print(f" - Queue Entries: {len(entries)}") # Only show distributions if appointments exist @@ -988,9 +1397,11 @@ def main(): 'slots': slots, 'appointments': appointments, 'sessions': sessions, + 'waiting_list_entries': waiting_list_entries, + 'contact_logs': contact_logs, 'entries': entries } if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/billing/__pycache__/models.cpython-312.pyc b/billing/__pycache__/models.cpython-312.pyc index f03e7e94..0ddd9217 100644 Binary files a/billing/__pycache__/models.cpython-312.pyc and b/billing/__pycache__/models.cpython-312.pyc differ diff --git a/billing/__pycache__/views.cpython-312.pyc b/billing/__pycache__/views.cpython-312.pyc index 5333c77f..597607fa 100644 Binary files a/billing/__pycache__/views.cpython-312.pyc and b/billing/__pycache__/views.cpython-312.pyc differ diff --git a/billing/migrations/0001_initial.py b/billing/migrations/0001_initial.py index ca301558..4a473009 100644 --- a/billing/migrations/0001_initial.py +++ b/billing/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-09-19 10:58 +# Generated by Django 5.2.6 on 2025-09-26 18:33 import billing.utils import django.db.models.deletion @@ -161,44 +161,41 @@ class Migration(migrations.Migration): ), ( "place_of_service", - models.CharField( + models.IntegerField( choices=[ - ("11", "Office"), - ("12", "Home"), - ("21", "Inpatient Hospital"), - ("22", "Outpatient Hospital"), - ("23", "Emergency Room"), - ("24", "Ambulatory Surgical Center"), - ("25", "Birthing Center"), - ("26", "Military Treatment Facility"), - ("31", "Skilled Nursing Facility"), - ("32", "Nursing Facility"), - ("33", "Custodial Care Facility"), - ("34", "Hospice"), - ("41", "Ambulance - Land"), - ("42", "Ambulance - Air or Water"), - ("49", "Independent Clinic"), - ("50", "Federally Qualified Health Center"), - ("51", "Inpatient Psychiatric Facility"), - ("52", "Psychiatric Facility-Partial Hospitalization"), - ("53", "Community Mental Health Center"), - ("54", "Intermediate Care Facility/Mentally Retarded"), - ("55", "Residential Substance Abuse Treatment Facility"), - ("56", "Psychiatric Residential Treatment Center"), - ( - "57", - "Non-residential Substance Abuse Treatment Facility", - ), - ("60", "Mass Immunization Center"), - ("61", "Comprehensive Inpatient Rehabilitation Facility"), - ("62", "Comprehensive Outpatient Rehabilitation Facility"), - ("65", "End-Stage Renal Disease Treatment Facility"), - ("71", "Public Health Clinic"), - ("72", "Rural Health Clinic"), - ("81", "Independent Laboratory"), - ("99", "Other Place of Service"), + (11, "Office"), + (12, "Home"), + (21, "Inpatient Hospital"), + (22, "Outpatient Hospital"), + (23, "Emergency Room"), + (24, "Ambulatory Surgical Center"), + (25, "Birthing Center"), + (26, "Military Treatment Facility"), + (31, "Skilled Nursing Facility"), + (32, "Nursing Facility"), + (33, "Custodial Care Facility"), + (34, "Hospice"), + (41, "Ambulance - Land"), + (42, "Ambulance - Air or Water"), + (49, "Independent Clinic"), + (50, "Federally Qualified Health Center"), + (51, "Inpatient Psychiatric Facility"), + (52, "Psychiatric Facility-Partial Hospitalization"), + (53, "Community Mental Health Center"), + (54, "Intermediate Care Facility/Mentally Retarded"), + (55, "Residential Substance Abuse Treatment Facility"), + (56, "Psychiatric Residential Treatment Center"), + (57, "Non-residential Substance Abuse Treatment Facility"), + (60, "Mass Immunization Center"), + (61, "Comprehensive Inpatient Rehabilitation Facility"), + (62, "Comprehensive Outpatient Rehabilitation Facility"), + (65, "End-Stage Renal Disease Treatment Facility"), + (71, "Public Health Clinic"), + (72, "Rural Health Clinic"), + (81, "Independent Laboratory"), + (99, "Other Place of Service"), ], - default="22", + default=22, help_text="Place of service code", max_length=5, ), diff --git a/billing/migrations/0002_initial.py b/billing/migrations/0002_initial.py index 8f2026e1..2ba64dc9 100644 --- a/billing/migrations/0002_initial.py +++ b/billing/migrations/0002_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-09-19 10:58 +# Generated by Django 5.2.6 on 2025-09-26 18:33 import django.db.models.deletion from django.conf import settings diff --git a/billing/migrations/0003_initial.py b/billing/migrations/0003_initial.py index 432afa39..a0700f1f 100644 --- a/billing/migrations/0003_initial.py +++ b/billing/migrations/0003_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-09-19 10:58 +# Generated by Django 5.2.6 on 2025-09-26 18:33 import django.db.models.deletion from django.conf import settings diff --git a/billing/migrations/0004_alter_billlineitem_place_of_service.py b/billing/migrations/0004_alter_billlineitem_place_of_service.py new file mode 100644 index 00000000..58f92372 --- /dev/null +++ b/billing/migrations/0004_alter_billlineitem_place_of_service.py @@ -0,0 +1,54 @@ +# Generated by Django 5.2.6 on 2025-09-28 13:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("billing", "0003_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="billlineitem", + name="place_of_service", + field=models.IntegerField( + choices=[ + (11, "Office"), + (12, "Home"), + (21, "Inpatient Hospital"), + (22, "Outpatient Hospital"), + (23, "Emergency Room"), + (24, "Ambulatory Surgical Center"), + (25, "Birthing Center"), + (26, "Military Treatment Facility"), + (31, "Skilled Nursing Facility"), + (32, "Nursing Facility"), + (33, "Custodial Care Facility"), + (34, "Hospice"), + (41, "Ambulance - Land"), + (42, "Ambulance - Air or Water"), + (49, "Independent Clinic"), + (50, "Federally Qualified Health Center"), + (51, "Inpatient Psychiatric Facility"), + (52, "Psychiatric Facility-Partial Hospitalization"), + (53, "Community Mental Health Center"), + (54, "Intermediate Care Facility/Mentally Retarded"), + (55, "Residential Substance Abuse Treatment Facility"), + (56, "Psychiatric Residential Treatment Center"), + (57, "Non-residential Substance Abuse Treatment Facility"), + (60, "Mass Immunization Center"), + (61, "Comprehensive Inpatient Rehabilitation Facility"), + (62, "Comprehensive Outpatient Rehabilitation Facility"), + (65, "End-Stage Renal Disease Treatment Facility"), + (71, "Public Health Clinic"), + (72, "Rural Health Clinic"), + (81, "Independent Laboratory"), + (99, "Other Place of Service"), + ], + default=22, + help_text="Place of service code", + ), + ), + ] diff --git a/billing/migrations/__pycache__/0001_initial.cpython-312.pyc b/billing/migrations/__pycache__/0001_initial.cpython-312.pyc index c349a597..7ea752a9 100644 Binary files a/billing/migrations/__pycache__/0001_initial.cpython-312.pyc and b/billing/migrations/__pycache__/0001_initial.cpython-312.pyc differ diff --git a/billing/migrations/__pycache__/0002_initial.cpython-312.pyc b/billing/migrations/__pycache__/0002_initial.cpython-312.pyc index 57e3cb57..35c090f1 100644 Binary files a/billing/migrations/__pycache__/0002_initial.cpython-312.pyc and b/billing/migrations/__pycache__/0002_initial.cpython-312.pyc differ diff --git a/billing/migrations/__pycache__/0003_initial.cpython-312.pyc b/billing/migrations/__pycache__/0003_initial.cpython-312.pyc index a4b44bc7..9e112838 100644 Binary files a/billing/migrations/__pycache__/0003_initial.cpython-312.pyc and b/billing/migrations/__pycache__/0003_initial.cpython-312.pyc differ diff --git a/billing/migrations/__pycache__/0004_alter_billlineitem_place_of_service.cpython-312.pyc b/billing/migrations/__pycache__/0004_alter_billlineitem_place_of_service.cpython-312.pyc new file mode 100644 index 00000000..54ca0326 Binary files /dev/null and b/billing/migrations/__pycache__/0004_alter_billlineitem_place_of_service.cpython-312.pyc differ diff --git a/billing/models.py b/billing/models.py index cd8307ff..11f517f5 100644 --- a/billing/models.py +++ b/billing/models.py @@ -18,7 +18,45 @@ class MedicalBill(models.Model): """ Medical bill model for patient billing and revenue cycle management. """ - + + class BillType(models.TextChoices): + INPATIENT = 'INPATIENT', 'Inpatient' + OUTPATIENT = 'OUTPATIENT', 'Outpatient' + EMERGENCY = 'EMERGENCY', 'Emergency' + SURGERY = 'SURGERY', 'Surgery' + LABORATORY = 'LABORATORY', 'Laboratory' + RADIOLOGY = 'RADIOLOGY', 'Radiology' + PHARMACY = 'PHARMACY', 'Pharmacy' + PROFESSIONAL = 'PROFESSIONAL', 'Professional Services' + FACILITY = 'FACILITY', 'Facility Charges' + ANCILLARY = 'ANCILLARY', 'Ancillary Services' + + class BillStatus(models.TextChoices): + DRAFT = 'DRAFT', 'Draft' + PENDING = 'PENDING', 'Pending' + SUBMITTED = 'SUBMITTED', 'Submitted' + PARTIAL_PAID = 'PARTIAL_PAID', 'Partially Paid' # kept as provided + PAID = 'PAID', 'Paid' + OVERDUE = 'OVERDUE', 'Overdue' + COLLECTIONS = 'COLLECTIONS', 'Collections' + WRITTEN_OFF = 'WRITTEN_OFF', 'Written Off' + CANCELLED = 'CANCELLED', 'Cancelled' + + class PaymentTerms(models.TextChoices): + NET_30 = 'NET_30', 'Net 30 Days' + NET_60 = 'NET_60', 'Net 60 Days' + NET_90 = 'NET_90', 'Net 90 Days' + IMMEDIATE = 'IMMEDIATE', 'Immediate' + CUSTOM = 'CUSTOM', 'Custom Terms' + + class CollectionStatus(models.TextChoices): + NONE = 'NONE', 'None' + FIRST_NOTICE = 'FIRST_NOTICE', 'First Notice' + SECOND_NOTICE = 'SECOND_NOTICE', 'Second Notice' + FINAL_NOTICE = 'FINAL_NOTICE', 'Final Notice' + COLLECTIONS = 'COLLECTIONS', 'Collections' + LEGAL = 'LEGAL', 'Legal Action' + # Tenant relationship tenant = models.ForeignKey( 'core.Tenant', @@ -51,18 +89,7 @@ class MedicalBill(models.Model): # Bill Type and Category bill_type = models.CharField( max_length=20, - choices=[ - ('INPATIENT', 'Inpatient'), - ('OUTPATIENT', 'Outpatient'), - ('EMERGENCY', 'Emergency'), - ('SURGERY', 'Surgery'), - ('LABORATORY', 'Laboratory'), - ('RADIOLOGY', 'Radiology'), - ('PHARMACY', 'Pharmacy'), - ('PROFESSIONAL', 'Professional Services'), - ('FACILITY', 'Facility Charges'), - ('ANCILLARY', 'Ancillary Services'), - ], + choices=BillType.choices, help_text='Bill type' ) @@ -146,18 +173,8 @@ class MedicalBill(models.Model): # Bill Status status = models.CharField( max_length=20, - choices=[ - ('DRAFT', 'Draft'), - ('PENDING', 'Pending'), - ('SUBMITTED', 'Submitted'), - ('PARTIAL_PAID', 'Partially Paid'), - ('PAID', 'Paid'), - ('OVERDUE', 'Overdue'), - ('COLLECTIONS', 'Collections'), - ('WRITTEN_OFF', 'Written Off'), - ('CANCELLED', 'Cancelled'), - ], - default='DRAFT', + choices=BillStatus.choices, + default=BillStatus.DRAFT, help_text='Bill status' ) @@ -207,29 +224,16 @@ class MedicalBill(models.Model): # Payment Terms payment_terms = models.CharField( max_length=20, - choices=[ - ('NET_30', 'Net 30 Days'), - ('NET_60', 'Net 60 Days'), - ('NET_90', 'Net 90 Days'), - ('IMMEDIATE', 'Immediate'), - ('CUSTOM', 'Custom Terms'), - ], - default='NET_30', + choices=PaymentTerms.choices, + default=PaymentTerms.NET_30, help_text='Payment terms' ) # Collection Information collection_status = models.CharField( max_length=20, - choices=[ - ('NONE', 'None'), - ('FIRST_NOTICE', 'First Notice'), - ('SECOND_NOTICE', 'Second Notice'), - ('FINAL_NOTICE', 'Final Notice'), - ('COLLECTIONS', 'Collections'), - ('LEGAL', 'Legal Action'), - ], - default='NONE', + choices=CollectionStatus.choices, + default=CollectionStatus.NONE, help_text='Collection status' ) last_statement_date = models.DateField( @@ -318,73 +322,73 @@ class BillLineItem(models.Model): """ Bill line item model for detailed billing charges. """ - SERVICE_CATEGORY_CHOICES = [ - ('EVALUATION', 'Evaluation & Management'), - ('SURGERY', 'Surgery'), - ('RADIOLOGY', 'Radiology'), - ('PATHOLOGY', 'Pathology & Laboratory'), - ('MEDICINE', 'Medicine'), - ('ANESTHESIA', 'Anesthesia'), - ('SUPPLIES', 'Medical Supplies'), - ('PHARMACY', 'Pharmacy'), - ('ROOM_BOARD', 'Room & Board'), - ('NURSING', 'Nursing Services'), - ('THERAPY', 'Therapy Services'), - ('EMERGENCY', 'Emergency Services'), - ('AMBULANCE', 'Ambulance Services'), - ('DME', 'Durable Medical Equipment'), - ('OTHER', 'Other Services'), - ] - UNIT_OF_MEASURE_CHOICES = [ - ('EACH', 'Each'), - ('UNIT', 'Unit'), - ('HOUR', 'Hour'), - ('DAY', 'Day'), - ('VISIT', 'Visit'), - ('PROCEDURE', 'Procedure'), - ('DOSE', 'Dose'), - ('MILE', 'Mile'), - ('MINUTE', 'Minute'), - ] - PLACE_OF_SERVICE_CHOICES = [ - ('11', 'Office'), - ('12', 'Home'), - ('21', 'Inpatient Hospital'), - ('22', 'Outpatient Hospital'), - ('23', 'Emergency Room'), - ('24', 'Ambulatory Surgical Center'), - ('25', 'Birthing Center'), - ('26', 'Military Treatment Facility'), - ('31', 'Skilled Nursing Facility'), - ('32', 'Nursing Facility'), - ('33', 'Custodial Care Facility'), - ('34', 'Hospice'), - ('41', 'Ambulance - Land'), - ('42', 'Ambulance - Air or Water'), - ('49', 'Independent Clinic'), - ('50', 'Federally Qualified Health Center'), - ('51', 'Inpatient Psychiatric Facility'), - ('52', 'Psychiatric Facility-Partial Hospitalization'), - ('53', 'Community Mental Health Center'), - ('54', 'Intermediate Care Facility/Mentally Retarded'), - ('55', 'Residential Substance Abuse Treatment Facility'), - ('56', 'Psychiatric Residential Treatment Center'), - ('57', 'Non-residential Substance Abuse Treatment Facility'), - ('60', 'Mass Immunization Center'), - ('61', 'Comprehensive Inpatient Rehabilitation Facility'), - ('62', 'Comprehensive Outpatient Rehabilitation Facility'), - ('65', 'End-Stage Renal Disease Treatment Facility'), - ('71', 'Public Health Clinic'), - ('72', 'Rural Health Clinic'), - ('81', 'Independent Laboratory'), - ('99', 'Other Place of Service'), - ] - STATUS_CHOICES = [ - ('ACTIVE', 'Active'), - ('DENIED', 'Denied'), - ('ADJUSTED', 'Adjusted'), - ('VOIDED', 'Voided'), - ] + + class ServiceCategory(models.TextChoices): + EVALUATION = 'EVALUATION', 'Evaluation & Management' + SURGERY = 'SURGERY', 'Surgery' + RADIOLOGY = 'RADIOLOGY', 'Radiology' + PATHOLOGY = 'PATHOLOGY', 'Pathology & Laboratory' + MEDICINE = 'MEDICINE', 'Medicine' + ANESTHESIA = 'ANESTHESIA', 'Anesthesia' + SUPPLIES = 'SUPPLIES', 'Medical Supplies' + PHARMACY = 'PHARMACY', 'Pharmacy' + ROOM_BOARD = 'ROOM_BOARD', 'Room & Board' + NURSING = 'NURSING', 'Nursing Services' + THERAPY = 'THERAPY', 'Therapy Services' + EMERGENCY = 'EMERGENCY', 'Emergency Services' + AMBULANCE = 'AMBULANCE', 'Ambulance Services' + DME = 'DME', 'Durable Medical Equipment' + OTHER = 'OTHER', 'Other Services' + + class ServiceUnitOfMeasure(models.TextChoices): + EACH = 'EACH', 'Each' + UNIT = 'UNIT', 'Unit' + HOUR = 'HOUR', 'Hour' + DAY = 'DAY', 'Day' + VISIT = 'VISIT', 'Visit' + PROCEDURE = 'PROCEDURE', 'Procedure' + DOSE = 'DOSE', 'Dose' + MILE = 'MILE', 'Mile' + MINUTE = 'MINUTE', 'Minute' + + class PlaceOfService(models.IntegerChoices): + OFFICE = 11, 'Office' + HOME = 12, 'Home' + INPATIENT_HOSPITAL = 21, 'Inpatient Hospital' + OUTPATIENT_HOSPITAL = 22, 'Outpatient Hospital' + EMERGENCY_ROOM = 23, 'Emergency Room' + AMBULATORY_SURGICAL_CENTER = 24, 'Ambulatory Surgical Center' + BIRTHING_CENTER = 25, 'Birthing Center' + MILITARY_TREATMENT_FACILITY = 26, 'Military Treatment Facility' + SKILLED_NURSING_FACILITY = 31, 'Skilled Nursing Facility' + NURSING_FACILITY = 32, 'Nursing Facility' + CUSTODIAL_CARE_FACILITY = 33, 'Custodial Care Facility' + HOSPICE = 34, 'Hospice' + AMBULANCE_LAND = 41, 'Ambulance - Land' + AMBULANCE_AIR_OR_WATER = 42, 'Ambulance - Air or Water' + INDEPENDENT_CLINIC = 49, 'Independent Clinic' + FEDERALLY_QUALIFIED_HEALTH_CENTER = 50, 'Federally Qualified Health Center' + INPATIENT_PSYCHIATRIC_FACILITY = 51, 'Inpatient Psychiatric Facility' + PSYCHIATRIC_PARTIAL_HOSPITALIZATION = 52, 'Psychiatric Facility-Partial Hospitalization' + COMMUNITY_MENTAL_HEALTH_CENTER = 53, 'Community Mental Health Center' + INTERMEDIATE_CARE_FACILITY_MR = 54, 'Intermediate Care Facility/Mentally Retarded' + RESIDENTIAL_SUBSTANCE_ABUSE_TREATMENT = 55, 'Residential Substance Abuse Treatment Facility' + PSYCHIATRIC_RESIDENTIAL_TREATMENT_CENTER = 56, 'Psychiatric Residential Treatment Center' + NON_RESIDENTIAL_SUBSTANCE_ABUSE_TREATMENT = 57, 'Non-residential Substance Abuse Treatment Facility' + MASS_IMMUNIZATION_CENTER = 60, 'Mass Immunization Center' + COMPREHENSIVE_INPATIENT_REHAB_FACILITY = 61, 'Comprehensive Inpatient Rehabilitation Facility' + COMPREHENSIVE_OUTPATIENT_REHAB_FACILITY = 62, 'Comprehensive Outpatient Rehabilitation Facility' + ESRD_TREATMENT_FACILITY = 65, 'End-Stage Renal Disease Treatment Facility' + PUBLIC_HEALTH_CLINIC = 71, 'Public Health Clinic' + RURAL_HEALTH_CLINIC = 72, 'Rural Health Clinic' + INDEPENDENT_LABORATORY = 81, 'Independent Laboratory' + OTHER_PLACE_OF_SERVICE = 99, 'Other Place of Service' + + class ServiceStatus(models.TextChoices): + ACTIVE = 'ACTIVE', 'Active' + DENIED = 'DENIED', 'Denied' + ADJUSTED = 'ADJUSTED', 'Adjusted' + VOIDED = 'VOIDED', 'Voided' # Medical Bill relationship medical_bill = models.ForeignKey( MedicalBill, @@ -420,7 +424,7 @@ class BillLineItem(models.Model): # Service Category service_category = models.CharField( max_length=30, - choices=SERVICE_CATEGORY_CHOICES, + choices=ServiceCategory.choices, help_text='Service category' ) @@ -433,8 +437,8 @@ class BillLineItem(models.Model): ) unit_of_measure = models.CharField( max_length=20, - choices=UNIT_OF_MEASURE_CHOICES, - default='EACH', + choices=ServiceUnitOfMeasure.choices, + default=ServiceUnitOfMeasure.EACH, help_text='Unit of measure' ) @@ -507,10 +511,9 @@ class BillLineItem(models.Model): ) # Place of Service - place_of_service = models.CharField( - max_length=5, - choices=PLACE_OF_SERVICE_CHOICES, - default='22', + place_of_service = models.IntegerField( + choices=PlaceOfService.choices, + default=22, help_text='Place of service code' ) @@ -546,8 +549,8 @@ class BillLineItem(models.Model): # Line Item Status status = models.CharField( max_length=20, - choices=STATUS_CHOICES, - default='ACTIVE', + choices=ServiceStatus.choices, + default=ServiceStatus.ACTIVE, help_text='Line item status' ) @@ -604,25 +607,25 @@ class InsuranceClaim(models.Model): """ Insurance claim model for claims processing and management. """ - CLAIM_TYPE_CHOICES = [ - ('PRIMARY', 'Primary Claim'), - ('SECONDARY', 'Secondary Claim'), - ('TERTIARY', 'Tertiary Claim'), - ('CORRECTED', 'Corrected Claim'), - ('VOID', 'Void Claim'), - ('REPLACEMENT', 'Replacement Claim'), - ] - STATUS_CHOICES = [ - ('DRAFT', 'Draft'), - ('SUBMITTED', 'Submitted'), - ('PENDING', 'Pending'), - ('PROCESSING', 'Processing'), - ('PAID', 'Paid'), - ('DENIED', 'Denied'), - ('REJECTED', 'Rejected'), - ('APPEALED', 'Appealed'), - ('VOIDED', 'Voided'), - ] + + class ClaimSubmissionType(models.TextChoices): + PRIMARY = 'PRIMARY', 'Primary Claim' + SECONDARY = 'SECONDARY', 'Secondary Claim' + TERTIARY = 'TERTIARY', 'Tertiary Claim' + CORRECTED = 'CORRECTED', 'Corrected Claim' + VOID = 'VOID', 'Void Claim' + REPLACEMENT = 'REPLACEMENT', 'Replacement Claim' + + class ClaimProcessingStatus(models.TextChoices): + DRAFT = 'DRAFT', 'Draft' + SUBMITTED = 'SUBMITTED', 'Submitted' + PENDING = 'PENDING', 'Pending' + PROCESSING = 'PROCESSING', 'Processing' + PAID = 'PAID', 'Paid' + DENIED = 'DENIED', 'Denied' + REJECTED = 'REJECTED', 'Rejected' + APPEALED = 'APPEALED', 'Appealed' + VOIDED = 'VOIDED', 'Voided' # Medical Bill relationship medical_bill = models.ForeignKey( @@ -656,8 +659,8 @@ class InsuranceClaim(models.Model): # Claim Type claim_type = models.CharField( max_length=20, - choices=CLAIM_TYPE_CHOICES, - default='PRIMARY', + choices=ClaimSubmissionType.choices, + default=ClaimSubmissionType.PRIMARY, help_text='Claim type' ) @@ -718,8 +721,8 @@ class InsuranceClaim(models.Model): # Claim Status status = models.CharField( max_length=20, - choices=STATUS_CHOICES, - default='DRAFT', + choices=ClaimProcessingStatus.choices, + default=ClaimProcessingStatus.DRAFT, help_text='Claim status' ) @@ -881,43 +884,44 @@ class Payment(models.Model): """ Payment model for tracking payments and receipts. """ - PAYMENT_METHOD_CHOICES = [ - ('CASH', 'Cash'), - ('CHECK', 'Check'), - ('CREDIT_CARD', 'Credit Card'), - ('DEBIT_CARD', 'Debit Card'), - ('BANK_TRANSFER', 'Bank Transfer'), - ('ACH', 'ACH Transfer'), - ('WIRE', 'Wire Transfer'), - ('MONEY_ORDER', 'Money Order'), - ('INSURANCE', 'Insurance Payment'), - ('ADJUSTMENT', 'Adjustment'), - ('WRITE_OFF', 'Write Off'), - ('OTHER', 'Other'), - ] - PAYMENT_SOURCE_CHOICES = [ - ('PATIENT', 'Patient'), - ('INSURANCE', 'Insurance'), - ('GUARANTOR', 'Guarantor'), - ('GOVERNMENT', 'Government'), - ('CHARITY', 'Charity'), - ('OTHER', 'Other'), - ] - STATUS_CHOICES = [ - ('PENDING', 'Pending'), - ('PROCESSED', 'Processed'), - ('CLEARED', 'Cleared'), - ('BOUNCED', 'Bounced'), - ('REVERSED', 'Reversed'), - ('REFUNDED', 'Refunded'), - ] - CARD_TYPE_CHOICES = [ - ('VISA', 'Visa'), - ('MASTERCARD', 'MasterCard'), - ('AMEX', 'American Express'), - ('DISCOVER', 'Discover'), - ('OTHER', 'Other'), - ] + + class PaymentMethod(models.TextChoices): + CASH = 'CASH', 'Cash' + CHECK = 'CHECK', 'Check' + CREDIT_CARD = 'CREDIT_CARD', 'Credit Card' + DEBIT_CARD = 'DEBIT_CARD', 'Debit Card' + BANK_TRANSFER = 'BANK_TRANSFER', 'Bank Transfer' + ACH = 'ACH', 'ACH Transfer' + WIRE = 'WIRE', 'Wire Transfer' + MONEY_ORDER = 'MONEY_ORDER', 'Money Order' + INSURANCE = 'INSURANCE', 'Insurance Payment' + ADJUSTMENT = 'ADJUSTMENT', 'Adjustment' + WRITE_OFF = 'WRITE_OFF', 'Write Off' + OTHER = 'OTHER', 'Other' + + class PaymentSource(models.TextChoices): + PATIENT = 'PATIENT', 'Patient' + INSURANCE = 'INSURANCE', 'Insurance' + GUARANTOR = 'GUARANTOR', 'Guarantor' + GOVERNMENT = 'GOVERNMENT', 'Government' + CHARITY = 'CHARITY', 'Charity' + OTHER = 'OTHER', 'Other' + + class PaymentStatus(models.TextChoices): + PENDING = 'PENDING', 'Pending' + PROCESSED = 'PROCESSED', 'Processed' + CLEARED = 'CLEARED', 'Cleared' + BOUNCED = 'BOUNCED', 'Bounced' + REVERSED = 'REVERSED', 'Reversed' + REFUNDED = 'REFUNDED', 'Refunded' + + class CardType(models.TextChoices): + VISA = 'VISA', 'Visa' + MASTERCARD = 'MASTERCARD', 'MasterCard' + AMEX = 'AMEX', 'American Express' + DISCOVER = 'DISCOVER', 'Discover' + OTHER = 'OTHER', 'Other' + # Medical Bill relationship medical_bill = models.ForeignKey( MedicalBill, @@ -952,14 +956,14 @@ class Payment(models.Model): # Payment Method payment_method = models.CharField( max_length=20, - choices=PAYMENT_METHOD_CHOICES, + choices=PaymentMethod.choices, help_text='Payment method' ) # Payment Source payment_source = models.CharField( max_length=20, - choices=PAYMENT_SOURCE_CHOICES, + choices=PaymentSource.choices, help_text='Payment source' ) @@ -986,7 +990,7 @@ class Payment(models.Model): # Credit Card Information (encrypted/tokenized) card_type = models.CharField( max_length=20, - choices=CARD_TYPE_CHOICES, + choices=CardType.choices, blank=True, null=True, help_text='Credit card type' @@ -1029,8 +1033,8 @@ class Payment(models.Model): # Payment Status status = models.CharField( max_length=20, - choices=STATUS_CHOICES, - default='PENDING', + choices=PaymentStatus.choices, + default=PaymentStatus.PENDING, help_text='Payment status' ) @@ -1157,16 +1161,17 @@ class ClaimStatusUpdate(models.Model): """ Claim status update model for tracking claim processing history. """ - UPDATE_SOURCE_CHOICES = [ - ('MANUAL', 'Manual Update'), - ('EDI', 'EDI Response'), - ('PHONE', 'Phone Call'), - ('PORTAL', 'Insurance Portal'), - ('EMAIL', 'Email'), - ('FAX', 'Fax'), - ('MAIL', 'Mail'), - ('SYSTEM', 'System Generated'), - ] + + class UpdateSource(models.TextChoices): + MANUAL = 'MANUAL', 'Manual Update' + EDI = 'EDI', 'EDI Response' + PHONE = 'PHONE', 'Phone Call' + PORTAL = 'PORTAL', 'Insurance Portal' + EMAIL = 'EMAIL', 'Email' + FAX = 'FAX', 'Fax' + MAIL = 'MAIL', 'Mail' + SYSTEM = 'SYSTEM', 'System Generated' + # Insurance Claim relationship insurance_claim = models.ForeignKey( InsuranceClaim, @@ -1200,7 +1205,7 @@ class ClaimStatusUpdate(models.Model): # Update Details update_source = models.CharField( max_length=20, - choices=UPDATE_SOURCE_CHOICES, + choices=UpdateSource.choices, help_text='Update source' ) @@ -1293,22 +1298,23 @@ class BillingConfiguration(models.Model): """ Billing configuration model for tenant-specific billing settings. """ - DEFAULT_PAYMENT_TERMS_CHOICES = [ - ('NET_30', 'Net 30 Days'), - ('NET_60', 'Net 60 Days'), - ('NET_90', 'Net 90 Days'), - ('IMMEDIATE', 'Immediate'), - ] - STATEMENT_FREQUENCY_CHOICES = [ - ('MONTHLY', 'Monthly'), - ('QUARTERLY', 'Quarterly'), - ('ON_DEMAND', 'On Demand'), - ] - CLAIM_SUBMISSION_FREQUENCY_CHOICES = [ - ('DAILY', 'Daily'), - ('WEEKLY', 'Weekly'), - ('MANUAL', 'Manual'), - ] + + class DefaultPaymentTerms(models.TextChoices): + NET_30 = 'NET_30', 'Net 30 Days' + NET_60 = 'NET_60', 'Net 60 Days' + NET_90 = 'NET_90', 'Net 90 Days' + IMMEDIATE = 'IMMEDIATE', 'Immediate' + + class StatementFrequency(models.TextChoices): + MONTHLY = 'MONTHLY', 'Monthly' + QUARTERLY = 'QUARTERLY', 'Quarterly' + ON_DEMAND = 'ON_DEMAND', 'On Demand' + + class ClaimSubmissionFrequency(models.TextChoices): + DAILY = 'DAILY', 'Daily' + WEEKLY = 'WEEKLY', 'Weekly' + MANUAL = 'MANUAL', 'Manual' + # Tenant relationship tenant = models.OneToOneField( 'core.Tenant', @@ -1328,8 +1334,8 @@ class BillingConfiguration(models.Model): # Billing Settings default_payment_terms = models.CharField( max_length=20, - choices=DEFAULT_PAYMENT_TERMS_CHOICES, - default='NET_30', + choices=DefaultPaymentTerms.choices, + default=DefaultPaymentTerms.NET_30, help_text='Default payment terms' ) @@ -1348,8 +1354,8 @@ class BillingConfiguration(models.Model): # Statement Settings statement_frequency = models.CharField( max_length=20, - choices=STATEMENT_FREQUENCY_CHOICES, - default='MONTHLY', + choices=StatementFrequency.choices, + default=StatementFrequency.MONTHLY, help_text='Statement frequency' ) statement_message = models.TextField( @@ -1409,8 +1415,8 @@ class BillingConfiguration(models.Model): ) claim_submission_frequency = models.CharField( max_length=20, - choices=CLAIM_SUBMISSION_FREQUENCY_CHOICES, - default='DAILY', + choices=ClaimSubmissionFrequency.choices, + default=ClaimSubmissionFrequency.DAILY, help_text='Claim submission frequency' ) diff --git a/billing/views.py b/billing/views.py index 1ba220a6..919764da 100644 --- a/billing/views.py +++ b/billing/views.py @@ -44,7 +44,7 @@ class BillingDashboardView(LoginRequiredMixin, TemplateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if tenant: today = timezone.now().date() @@ -110,7 +110,7 @@ class MedicalBillListView(LoginRequiredMixin, ListView): paginate_by = 25 def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return MedicalBill.objects.none() @@ -184,7 +184,7 @@ class MedicalBillDetailView(LoginRequiredMixin, DetailView): slug_url_kwarg = 'bill_id' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return MedicalBill.objects.none() return MedicalBill.objects.filter(tenant=tenant) @@ -217,7 +217,7 @@ class MedicalBillCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateV def form_valid(self, form): # Set tenant and created_by - form.instance.tenant = getattr(self.request, 'tenant', None) + form.instance.tenant = self.request.user.tenant form.instance.created_by = self.request.user # Generate bill number @@ -249,7 +249,7 @@ class MedicalBillUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateV slug_url_kwarg = 'bill_id' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return MedicalBill.objects.none() return MedicalBill.objects.filter(tenant=tenant) @@ -285,7 +285,7 @@ class MedicalBillDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteV slug_url_kwarg = 'bill_id' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return MedicalBill.objects.none() return MedicalBill.objects.filter(tenant=tenant) @@ -314,7 +314,7 @@ class InsuranceClaimListView(LoginRequiredMixin, ListView): paginate_by = 25 def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return InsuranceClaim.objects.none() @@ -372,7 +372,7 @@ class InsuranceClaimDetailView(LoginRequiredMixin, DetailView): slug_url_kwarg = 'claim_id' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return InsuranceClaim.objects.none() return InsuranceClaim.objects.filter(medical_bill__tenant=tenant) @@ -406,7 +406,7 @@ class InsuranceClaimCreateView(LoginRequiredMixin, PermissionRequiredMixin, Crea try: medical_bill = MedicalBill.objects.get( bill_id=bill_id, - tenant=getattr(self.request, 'tenant', None) + tenant=self.request.user.tenant ) kwargs['medical_bill'] = medical_bill except MedicalBill.DoesNotExist: @@ -421,7 +421,7 @@ class InsuranceClaimCreateView(LoginRequiredMixin, PermissionRequiredMixin, Crea try: medical_bill = MedicalBill.objects.get( bill_id=bill_id, - tenant=getattr(self.request, 'tenant', None) + tenant=self.request.user.tenant ) form.instance.medical_bill = medical_bill except MedicalBill.DoesNotExist: @@ -442,7 +442,7 @@ class InsuranceClaimCreateView(LoginRequiredMixin, PermissionRequiredMixin, Crea def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant ctx['available_bills'] = MedicalBill.objects.filter(tenant=tenant).select_related('patient') return ctx @@ -466,7 +466,7 @@ class InsuranceClaimUpdateView(LoginRequiredMixin, PermissionRequiredMixin, Upda try: medical_bill = MedicalBill.objects.get( bill_id=bill_id, - tenant=getattr(self.request, 'tenant', None) + tenant=self.request.user.tenant ) kwargs['medical_bill'] = medical_bill except MedicalBill.DoesNotExist: @@ -481,7 +481,7 @@ class InsuranceClaimUpdateView(LoginRequiredMixin, PermissionRequiredMixin, Upda try: medical_bill = MedicalBill.objects.get( bill_id=bill_id, - tenant=getattr(self.request, 'tenant', None) + tenant=self.request.user.tenant ) form.instance.medical_bill = medical_bill except MedicalBill.DoesNotExist: @@ -502,7 +502,7 @@ class InsuranceClaimUpdateView(LoginRequiredMixin, PermissionRequiredMixin, Upda def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant ctx['available_bills'] = MedicalBill.objects.filter(tenant=tenant).select_related('patient') return ctx @@ -520,7 +520,7 @@ class PaymentListView(LoginRequiredMixin, ListView): paginate_by = 25 def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return Payment.objects.none() @@ -579,7 +579,7 @@ class PaymentDetailView(LoginRequiredMixin, DetailView): slug_url_kwarg = 'payment_id' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return Payment.objects.none() return Payment.objects.filter(medical_bill__tenant=tenant) @@ -604,7 +604,7 @@ class PaymentCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView) try: medical_bill = MedicalBill.objects.get( bill_id=bill_id, - tenant=getattr(self.request, 'tenant', None) + tenant=self.request.user.tenant ) kwargs['medical_bill'] = medical_bill except MedicalBill.DoesNotExist: @@ -619,7 +619,7 @@ class PaymentCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView) try: medical_bill = MedicalBill.objects.get( bill_id=bill_id, - tenant=getattr(self.request, 'tenant', None) + tenant=self.request.user.tenant ) form.instance.medical_bill = medical_bill except MedicalBill.DoesNotExist: diff --git a/blood_bank/__pycache__/views.cpython-312.pyc b/blood_bank/__pycache__/views.cpython-312.pyc index c8323144..2adf8ffc 100644 Binary files a/blood_bank/__pycache__/views.cpython-312.pyc and b/blood_bank/__pycache__/views.cpython-312.pyc differ diff --git a/blood_bank/migrations/0001_initial.py b/blood_bank/migrations/0001_initial.py index 153c16ea..04dbaf01 100644 --- a/blood_bank/migrations/0001_initial.py +++ b/blood_bank/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-09-19 10:58 +# Generated by Django 5.2.6 on 2025-09-26 18:33 import django.core.validators import django.db.models.deletion diff --git a/blood_bank/migrations/0002_initial.py b/blood_bank/migrations/0002_initial.py index 51df3400..f730281a 100644 --- a/blood_bank/migrations/0002_initial.py +++ b/blood_bank/migrations/0002_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-09-19 10:58 +# Generated by Django 5.2.6 on 2025-09-26 18:33 import django.db.models.deletion from django.conf import settings diff --git a/blood_bank/migrations/__pycache__/0001_initial.cpython-312.pyc b/blood_bank/migrations/__pycache__/0001_initial.cpython-312.pyc index 9d8d8cda..05663b9d 100644 Binary files a/blood_bank/migrations/__pycache__/0001_initial.cpython-312.pyc and b/blood_bank/migrations/__pycache__/0001_initial.cpython-312.pyc differ diff --git a/blood_bank/migrations/__pycache__/0002_initial.cpython-312.pyc b/blood_bank/migrations/__pycache__/0002_initial.cpython-312.pyc index 289dbcc2..137b9b48 100644 Binary files a/blood_bank/migrations/__pycache__/0002_initial.cpython-312.pyc and b/blood_bank/migrations/__pycache__/0002_initial.cpython-312.pyc differ diff --git a/blood_bank/views.py b/blood_bank/views.py index 3303210e..f892478e 100644 --- a/blood_bank/views.py +++ b/blood_bank/views.py @@ -98,7 +98,7 @@ class DonorListView(LoginRequiredMixin, ListView): context.update({ 'blood_groups': BloodGroup.objects.all(), - 'status_choices': Donor.STATUS_CHOICES, + 'status_choices': Donor.DonorStatus.choices, 'search_query': self.request.GET.get('search'), 'status_filter': self.request.GET.get('status'), 'blood_group_filter': self.request.GET.get('blood_group'), diff --git a/communications/__pycache__/models.cpython-312.pyc b/communications/__pycache__/models.cpython-312.pyc index 88510be8..cf676197 100644 Binary files a/communications/__pycache__/models.cpython-312.pyc and b/communications/__pycache__/models.cpython-312.pyc differ diff --git a/communications/migrations/0001_initial.py b/communications/migrations/0001_initial.py index 8aeb7ffa..005d7daf 100644 --- a/communications/migrations/0001_initial.py +++ b/communications/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-09-19 10:58 +# Generated by Django 5.2.6 on 2025-09-26 18:33 import django.db.models.deletion import uuid diff --git a/communications/migrations/0002_initial.py b/communications/migrations/0002_initial.py index 7ee7f2d0..896eb9cf 100644 --- a/communications/migrations/0002_initial.py +++ b/communications/migrations/0002_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-09-19 10:58 +# Generated by Django 5.2.6 on 2025-09-26 18:33 import django.db.models.deletion from django.conf import settings diff --git a/communications/migrations/__pycache__/0001_initial.cpython-312.pyc b/communications/migrations/__pycache__/0001_initial.cpython-312.pyc index 77d6c87c..4061a4d8 100644 Binary files a/communications/migrations/__pycache__/0001_initial.cpython-312.pyc and b/communications/migrations/__pycache__/0001_initial.cpython-312.pyc differ diff --git a/communications/migrations/__pycache__/0002_initial.cpython-312.pyc b/communications/migrations/__pycache__/0002_initial.cpython-312.pyc index 289a98ed..65963b1a 100644 Binary files a/communications/migrations/__pycache__/0002_initial.cpython-312.pyc and b/communications/migrations/__pycache__/0002_initial.cpython-312.pyc differ diff --git a/communications/models.py b/communications/models.py index 7555a2b9..196049a5 100644 --- a/communications/models.py +++ b/communications/models.py @@ -16,36 +16,34 @@ class Message(models.Model): """ Model for internal messaging system. """ - MESSAGE_TYPES = [ - ('INTERNAL', 'Internal Message'), - ('EMAIL', 'Email'), - ('SMS', 'SMS'), - ('PUSH', 'Push Notification'), - ('SLACK', 'Slack Message'), - ('TEAMS', 'Microsoft Teams'), - ('WEBHOOK', 'Webhook'), - ('SYSTEM', 'System Message'), - ('ALERT', 'Alert Message'), - ] - - PRIORITY_LEVELS = [ - ('LOW', 'Low'), - ('NORMAL', 'Normal'), - ('HIGH', 'High'), - ('URGENT', 'Urgent'), - ('CRITICAL', 'Critical'), - ] - - STATUS_CHOICES = [ - ('DRAFT', 'Draft'), - ('PENDING', 'Pending'), - ('SENDING', 'Sending'), - ('SENT', 'Sent'), - ('DELIVERED', 'Delivered'), - ('READ', 'Read'), - ('FAILED', 'Failed'), - ('CANCELLED', 'Cancelled'), - ] + + class MessageType(models.TextChoices): + INTERNAL = 'INTERNAL', 'Internal Message' + EMAIL = 'EMAIL', 'Email' + SMS = 'SMS', 'SMS' + PUSH = 'PUSH', 'Push Notification' + SLACK = 'SLACK', 'Slack Message' + TEAMS = 'TEAMS', 'Microsoft Teams' + WEBHOOK = 'WEBHOOK', 'Webhook' + SYSTEM = 'SYSTEM', 'System Message' + ALERT = 'ALERT', 'Alert Message' + + class PriorityLevel(models.TextChoices): + LOW = 'LOW', 'Low' + NORMAL = 'NORMAL', 'Normal' + HIGH = 'HIGH', 'High' + URGENT = 'URGENT', 'Urgent' + CRITICAL = 'CRITICAL', 'Critical' + + class MessageStatus(models.TextChoices): + DRAFT = 'DRAFT', 'Draft' + PENDING = 'PENDING', 'Pending' + SENDING = 'SENDING', 'Sending' + SENT = 'SENT', 'Sent' + DELIVERED = 'DELIVERED', 'Delivered' + READ = 'READ', 'Read' + FAILED = 'FAILED', 'Failed' + CANCELLED = 'CANCELLED', 'Cancelled' # Primary identification message_id = models.UUIDField( @@ -70,14 +68,14 @@ class Message(models.Model): ) message_type = models.CharField( max_length=20, - choices=MESSAGE_TYPES, - default='INTERNAL', + choices=MessageType.choices, + default=MessageType.INTERNAL, help_text="Type of message" ) priority = models.CharField( max_length=20, - choices=PRIORITY_LEVELS, - default='NORMAL', + choices=PriorityLevel.choices, + default=PriorityLevel.NORMAL, help_text="Message priority level" ) @@ -92,8 +90,8 @@ class Message(models.Model): # Message status status = models.CharField( max_length=20, - choices=STATUS_CHOICES, - default='DRAFT', + choices=MessageStatus.choices, + default=MessageStatus.DRAFT, help_text="Message status" ) @@ -205,28 +203,24 @@ class Message(models.Model): class MessageRecipient(models.Model): - """ - Model for message recipients. - """ - RECIPIENT_TYPES = [ - ('USER', 'User'), - ('EMAIL', 'Email Address'), - ('PHONE', 'Phone Number'), - ('ROLE', 'User Role'), - ('DEPARTMENT', 'Department'), - ('GROUP', 'User Group'), - ] - - STATUS_CHOICES = [ - ('PENDING', 'Pending'), - ('SENT', 'Sent'), - ('DELIVERED', 'Delivered'), - ('READ', 'Read'), - ('ACKNOWLEDGED', 'Acknowledged'), - ('FAILED', 'Failed'), - ('BOUNCED', 'Bounced'), - ('UNSUBSCRIBED', 'Unsubscribed'), - ] + + class RecipientType(models.TextChoices): + USER = 'USER', 'User' + EMAIL = 'EMAIL', 'Email Address' + PHONE = 'PHONE', 'Phone Number' + ROLE = 'ROLE', 'User Role' + DEPARTMENT = 'DEPARTMENT', 'Department' + GROUP = 'GROUP', 'User Group' + + class RecipientStatus(models.TextChoices): + PENDING = 'PENDING', 'Pending' + SENT = 'SENT', 'Sent' + DELIVERED = 'DELIVERED', 'Delivered' + READ = 'READ', 'Read' + ACKNOWLEDGED = 'ACKNOWLEDGED', 'Acknowledged' + FAILED = 'FAILED', 'Failed' + BOUNCED = 'BOUNCED', 'Bounced' + UNSUBSCRIBED = 'UNSUBSCRIBED', 'Unsubscribed' # Primary identification recipient_id = models.UUIDField( @@ -245,7 +239,7 @@ class MessageRecipient(models.Model): # Recipient details recipient_type = models.CharField( max_length=20, - choices=RECIPIENT_TYPES, + choices=RecipientType.choices, help_text="Type of recipient" ) user = models.ForeignKey( @@ -276,8 +270,8 @@ class MessageRecipient(models.Model): # Delivery status status = models.CharField( max_length=20, - choices=STATUS_CHOICES, - default='PENDING', + choices=RecipientStatus.choices, + default=RecipientStatus.PENDING, help_text="Delivery status" ) @@ -355,28 +349,27 @@ class NotificationTemplate(models.Model): """ Model for notification templates. """ - TEMPLATE_TYPES = [ - ('EMAIL', 'Email Template'), - ('SMS', 'SMS Template'), - ('PUSH', 'Push Notification Template'), - ('SLACK', 'Slack Template'), - ('TEAMS', 'Teams Template'), - ('WEBHOOK', 'Webhook Template'), - ('SYSTEM', 'System Notification Template'), - ] - - TEMPLATE_CATEGORIES = [ - ('APPOINTMENT', 'Appointment Notifications'), - ('MEDICATION', 'Medication Reminders'), - ('LAB_RESULTS', 'Lab Results'), - ('BILLING', 'Billing Notifications'), - ('EMERGENCY', 'Emergency Alerts'), - ('SYSTEM', 'System Notifications'), - ('MARKETING', 'Marketing Communications'), - ('CLINICAL', 'Clinical Notifications'), - ('ADMINISTRATIVE', 'Administrative Messages'), - ('QUALITY', 'Quality Alerts'), - ] + + class TemplateType(models.TextChoices): + EMAIL = 'EMAIL', 'Email Template' + SMS = 'SMS', 'SMS Template' + PUSH = 'PUSH', 'Push Notification Template' + SLACK = 'SLACK', 'Slack Template' + TEAMS = 'TEAMS', 'Teams Template' + WEBHOOK = 'WEBHOOK', 'Webhook Template' + SYSTEM = 'SYSTEM', 'System Notification Template' + + class TemplateCategory(models.TextChoices): + APPOINTMENT = 'APPOINTMENT', 'Appointment Notifications' + MEDICATION = 'MEDICATION', 'Medication Reminders' + LAB_RESULTS = 'LAB_RESULTS', 'Lab Results' + BILLING = 'BILLING', 'Billing Notifications' + EMERGENCY = 'EMERGENCY', 'Emergency Alerts' + SYSTEM = 'SYSTEM', 'System Notifications' + MARKETING = 'MARKETING', 'Marketing Communications' + CLINICAL = 'CLINICAL', 'Clinical Notifications' + ADMINISTRATIVE = 'ADMINISTRATIVE', 'Administrative Messages' + QUALITY = 'QUALITY', 'Quality Alerts' # Primary identification template_id = models.UUIDField( @@ -403,12 +396,12 @@ class NotificationTemplate(models.Model): ) template_type = models.CharField( max_length=20, - choices=TEMPLATE_TYPES, + choices=TemplateType.choices, help_text="Type of template" ) category = models.CharField( max_length=30, - choices=TEMPLATE_CATEGORIES, + choices=TemplateCategory.choices, help_text="Template category" ) @@ -497,24 +490,23 @@ class AlertRule(models.Model): """ Model for automated alert rules. """ - TRIGGER_TYPES = [ - ('THRESHOLD', 'Threshold Alert'), - ('PATTERN', 'Pattern Alert'), - ('SCHEDULE', 'Scheduled Alert'), - ('EVENT', 'Event-based Alert'), - ('ANOMALY', 'Anomaly Detection'), - ('SYSTEM', 'System Alert'), - ('CLINICAL', 'Clinical Alert'), - ('OPERATIONAL', 'Operational Alert'), - ] - - SEVERITY_LEVELS = [ - ('INFO', 'Information'), - ('WARNING', 'Warning'), - ('ERROR', 'Error'), - ('CRITICAL', 'Critical'), - ('EMERGENCY', 'Emergency'), - ] + + class TriggerType(models.TextChoices): + THRESHOLD = 'THRESHOLD', 'Threshold Alert' + PATTERN = 'PATTERN', 'Pattern Alert' + SCHEDULE = 'SCHEDULE', 'Scheduled Alert' + EVENT = 'EVENT', 'Event-based Alert' + ANOMALY = 'ANOMALY', 'Anomaly Detection' + SYSTEM = 'SYSTEM', 'System Alert' + CLINICAL = 'CLINICAL', 'Clinical Alert' + OPERATIONAL = 'OPERATIONAL', 'Operational Alert' + + class SeverityLevel(models.TextChoices): + INFO = 'INFO', 'Information' + WARNING = 'WARNING', 'Warning' + ERROR = 'ERROR', 'Error' + CRITICAL = 'CRITICAL', 'Critical' + EMERGENCY = 'EMERGENCY', 'Emergency' # Primary identification rule_id = models.UUIDField( @@ -541,13 +533,13 @@ class AlertRule(models.Model): ) trigger_type = models.CharField( max_length=20, - choices=TRIGGER_TYPES, + choices=TriggerType.choices, help_text="Type of alert trigger" ) severity = models.CharField( max_length=20, - choices=SEVERITY_LEVELS, - default='WARNING', + choices=SeverityLevel.choices, + default=SeverityLevel.WARNING, help_text="Alert severity level" ) @@ -665,14 +657,13 @@ class AlertInstance(models.Model): """ Model for alert instances. """ - STATUS_CHOICES = [ - ('ACTIVE', 'Active'), - ('ACKNOWLEDGED', 'Acknowledged'), - ('RESOLVED', 'Resolved'), - ('SUPPRESSED', 'Suppressed'), - ('ESCALATED', 'Escalated'), - ('EXPIRED', 'Expired'), - ] + class AlertStatus(models.TextChoices): + ACTIVE = 'ACTIVE', 'Active' + ACKNOWLEDGED = 'ACKNOWLEDGED', 'Acknowledged' + RESOLVED = 'RESOLVED', 'Resolved' + SUPPRESSED = 'SUPPRESSED', 'Suppressed' + ESCALATED = 'ESCALATED', 'Escalated' + EXPIRED = 'EXPIRED', 'Expired' # Primary identification alert_id = models.UUIDField( @@ -698,7 +689,7 @@ class AlertInstance(models.Model): ) severity = models.CharField( max_length=20, - choices=AlertRule.SEVERITY_LEVELS, + choices=AlertRule.SeverityLevel.choices, help_text="Alert severity level" ) @@ -715,8 +706,8 @@ class AlertInstance(models.Model): # Alert status status = models.CharField( max_length=20, - choices=STATUS_CHOICES, - default='ACTIVE', + choices=AlertStatus.choices, + default=AlertStatus.ACTIVE, help_text="Alert status" ) @@ -817,30 +808,29 @@ class CommunicationChannel(models.Model): """ Model for communication channels. """ - CHANNEL_TYPES = [ - ('EMAIL', 'Email'), - ('SMS', 'SMS'), - ('PUSH', 'Push Notification'), - ('SLACK', 'Slack'), - ('TEAMS', 'Microsoft Teams'), - ('WEBHOOK', 'Webhook'), - ('PHONE', 'Phone Call'), - ('FAX', 'Fax'), - ('PAGER', 'Pager'), - ] - - PROVIDER_TYPES = [ - ('SMTP', 'SMTP Email'), - ('SENDGRID', 'SendGrid'), - ('MAILGUN', 'Mailgun'), - ('TWILIO', 'Twilio SMS'), - ('AWS_SNS', 'AWS SNS'), - ('FIREBASE', 'Firebase'), - ('SLACK_API', 'Slack API'), - ('TEAMS_API', 'Teams API'), - ('WEBHOOK', 'Webhook'), - ('CUSTOM', 'Custom Provider'), - ] + + class ChannelType(models.TextChoices): + EMAIL = 'EMAIL', 'Email' + SMS = 'SMS', 'SMS' + PUSH = 'PUSH', 'Push Notification' + SLACK = 'SLACK', 'Slack' + TEAMS = 'TEAMS', 'Microsoft Teams' + WEBHOOK = 'WEBHOOK', 'Webhook' + PHONE = 'PHONE', 'Phone Call' + FAX = 'FAX', 'Fax' + PAGER = 'PAGER', 'Pager' + + class ProviderType(models.TextChoices): + SMTP = 'SMTP', 'SMTP Email' + SENDGRID = 'SENDGRID', 'SendGrid' + MAILGUN = 'MAILGUN', 'Mailgun' + TWILIO = 'TWILIO', 'Twilio SMS' + AWS_SNS = 'AWS_SNS', 'AWS SNS' + FIREBASE = 'FIREBASE', 'Firebase' + SLACK_API = 'SLACK_API', 'Slack API' + TEAMS_API = 'TEAMS_API', 'Teams API' + WEBHOOK = 'WEBHOOK', 'Webhook' + CUSTOM = 'CUSTOM', 'Custom Provider' # Primary identification channel_id = models.UUIDField( @@ -867,12 +857,12 @@ class CommunicationChannel(models.Model): ) channel_type = models.CharField( max_length=20, - choices=CHANNEL_TYPES, + choices=ChannelType.choices, help_text="Type of communication channel" ) provider_type = models.CharField( max_length=20, - choices=PROVIDER_TYPES, + choices=ProviderType.choices, help_text="Provider type" ) @@ -980,16 +970,16 @@ class DeliveryLog(models.Model): """ Model for delivery logging. """ - STATUS_CHOICES = [ - ('PENDING', 'Pending'), - ('PROCESSING', 'Processing'), - ('SENT', 'Sent'), - ('DELIVERED', 'Delivered'), - ('FAILED', 'Failed'), - ('BOUNCED', 'Bounced'), - ('REJECTED', 'Rejected'), - ('TIMEOUT', 'Timeout'), - ] + + class DeliveryStatus(models.TextChoices): + PENDING = 'PENDING', 'Pending' + PROCESSING = 'PROCESSING', 'Processing' + SENT = 'SENT', 'Sent' + DELIVERED = 'DELIVERED', 'Delivered' + FAILED = 'FAILED', 'Failed' + BOUNCED = 'BOUNCED', 'Bounced' + REJECTED = 'REJECTED', 'Rejected' + TIMEOUT = 'TIMEOUT', 'Timeout' # Primary identification log_id = models.UUIDField( @@ -1020,8 +1010,8 @@ class DeliveryLog(models.Model): # Delivery details status = models.CharField( max_length=20, - choices=STATUS_CHOICES, - default='PENDING', + choices=DeliveryStatus.choices, + default=DeliveryStatus.PENDING, help_text="Delivery status" ) attempt_number = models.PositiveIntegerField( diff --git a/core/__pycache__/middleware.cpython-312.pyc b/core/__pycache__/middleware.cpython-312.pyc index d8c29803..823b52eb 100644 Binary files a/core/__pycache__/middleware.cpython-312.pyc and b/core/__pycache__/middleware.cpython-312.pyc differ diff --git a/core/__pycache__/mixins.cpython-312.pyc b/core/__pycache__/mixins.cpython-312.pyc index 37551574..8784df97 100644 Binary files a/core/__pycache__/mixins.cpython-312.pyc and b/core/__pycache__/mixins.cpython-312.pyc differ diff --git a/core/__pycache__/models.cpython-312.pyc b/core/__pycache__/models.cpython-312.pyc index 90e74eae..31a33643 100644 Binary files a/core/__pycache__/models.cpython-312.pyc and b/core/__pycache__/models.cpython-312.pyc differ diff --git a/core/__pycache__/urls.cpython-312.pyc b/core/__pycache__/urls.cpython-312.pyc index 448a8d90..c10f7db6 100644 Binary files a/core/__pycache__/urls.cpython-312.pyc and b/core/__pycache__/urls.cpython-312.pyc differ diff --git a/core/__pycache__/views.cpython-312.pyc b/core/__pycache__/views.cpython-312.pyc index 9e9aae20..b01f6b6b 100644 Binary files a/core/__pycache__/views.cpython-312.pyc and b/core/__pycache__/views.cpython-312.pyc differ diff --git a/core/api/views.py b/core/api/views.py index bfdb5ccf..2513da5e 100644 --- a/core/api/views.py +++ b/core/api/views.py @@ -114,7 +114,7 @@ class SystemConfigurationViewSet(viewsets.ModelViewSet): def perform_create(self, serializer): serializer.save( - tenant=getattr(self.request, 'tenant', None), + tenant=self.request.user.tenant, updated_by=self.request.user ) @@ -161,7 +161,7 @@ class SystemNotificationViewSet(viewsets.ModelViewSet): def perform_create(self, serializer): serializer.save( - tenant=getattr(self.request, 'tenant', None), + tenant=self.request.user.tenant, created_by=self.request.user ) diff --git a/core/middleware.py b/core/middleware.py index 29d45f82..e1704494 100644 --- a/core/middleware.py +++ b/core/middleware.py @@ -1,67 +1,67 @@ -""" -Tenant middleware for multi-tenancy support. -""" - -from django.http import Http404 -from django.shortcuts import get_object_or_404 -from .models import Tenant - - -class TenantMiddleware: - """ - Middleware to handle tenant resolution and context. - """ - - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request): - # Skip tenant resolution for admin and API endpoints - if request.path.startswith('/admin/') or request.path.startswith('/api/'): - response = self.get_response(request) - return response - - # Get tenant from subdomain or header - tenant = self.get_tenant(request) - - if tenant: - request.tenant = tenant - else: - # For development, use the first available tenant - try: - request.tenant = Tenant.objects.filter(is_active=True).first() - except Tenant.DoesNotExist: - request.tenant = None - - response = self.get_response(request) - return response - - def get_tenant(self, request): - """ - Resolve tenant from request. - """ - # Try to get tenant from subdomain - host = request.get_host() - if '.' in host: - subdomain = host.split('.')[0] - try: - return Tenant.objects.get( - name__iexact=subdomain, - is_active=True - ) - except Tenant.DoesNotExist: - pass - - # Try to get tenant from header (for API calls) - tenant_id = request.headers.get('X-Tenant-ID') - if tenant_id: - try: - return Tenant.objects.get( - tenant_id=tenant_id, - is_active=True - ) - except Tenant.DoesNotExist: - pass - - return None - +# """ +# Tenant middleware for multi-tenancy support. +# """ +# +# from django.http import Http404 +# from django.shortcuts import get_object_or_404 +# from .models import Tenant +# +# +# class TenantMiddleware: +# """ +# Middleware to handle tenant resolution and context. +# """ +# +# def __init__(self, get_response): +# self.get_response = get_response +# +# def __call__(self, request): +# # Skip tenant resolution for admin and API endpoints +# if request.path.startswith('/admin/') or request.path.startswith('/api/'): +# response = self.get_response(request) +# return response +# +# # Get tenant from subdomain or header +# tenant = self.get_tenant(request) +# +# if tenant: +# request.tenant = tenant +# else: +# # For development, use the first available tenant +# try: +# request.tenant = Tenant.objects.filter(is_active=True).first() +# except Tenant.DoesNotExist: +# request.tenant = None +# +# response = self.get_response(request) +# return response +# +# def get_tenant(self, request): +# """ +# Resolve tenant from request. +# """ +# # Try to get tenant from subdomain +# host = request.get_host() +# if '.' in host: +# subdomain = host.split('.')[0] +# try: +# return Tenant.objects.get( +# name__iexact=subdomain, +# is_active=True +# ) +# except Tenant.DoesNotExist: +# pass +# +# # Try to get tenant from header (for API calls) +# tenant_id = request.headers.get('X-Tenant-ID') +# if tenant_id: +# try: +# return Tenant.objects.get( +# tenant_id=tenant_id, +# is_active=True +# ) +# except Tenant.DoesNotExist: +# pass +# +# return None +# diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py index 6339d10b..01651b98 100644 --- a/core/migrations/0001_initial.py +++ b/core/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-09-19 10:58 +# Generated by Django 5.2.6 on 2025-09-26 18:33 import django.core.validators import django.db.models.deletion diff --git a/core/migrations/__pycache__/0001_initial.cpython-312.pyc b/core/migrations/__pycache__/0001_initial.cpython-312.pyc index 858f4509..87e55244 100644 Binary files a/core/migrations/__pycache__/0001_initial.cpython-312.pyc and b/core/migrations/__pycache__/0001_initial.cpython-312.pyc differ diff --git a/core/mixins.py b/core/mixins.py index 87bd5dbf..0d5e40b8 100644 --- a/core/mixins.py +++ b/core/mixins.py @@ -4,7 +4,7 @@ class TenantMixin: def get_queryset(self): qs = super().get_queryset() - tenant = getattr(self.request.user, 'tenant', None) + tenant = self.request.user.tenant if tenant and not self.request.user.is_superuser: # Models with patient FK: if hasattr(qs.model, 'patient'): @@ -22,5 +22,5 @@ class FormKwargsMixin: def get_form_kwargs(self): kw = super().get_form_kwargs() kw['user'] = self.request.user - kw['tenant'] = getattr(self.request.user, 'tenant', None) + kw['tenant'] = self.request.user.tenant return kw \ No newline at end of file diff --git a/core/models.py b/core/models.py index 069b2373..049509f6 100644 --- a/core/models.py +++ b/core/models.py @@ -20,23 +20,22 @@ class Tenant(models.Model): Each tenant represents a separate hospital or healthcare organization. """ - ORGANIZATION_TYPE_CHOICES = [ - ('HOSPITAL', 'Hospital'), - ('CLINIC', 'Clinic'), - ('HEALTH_SYSTEM', 'Health System'), - ('AMBULATORY', 'Ambulatory Care'), - ('SPECIALTY', 'Specialty Practice'), - ('URGENT_CARE', 'Urgent Care'), - ('REHABILITATION', 'Rehabilitation Center'), - ('LONG_TERM_CARE', 'Long-term Care'), - ] + class OrganizationType(models.TextChoices): + HOSPITAL = 'HOSPITAL', 'Hospital' + CLINIC = 'CLINIC', 'Clinic' + HEALTH_SYSTEM = 'HEALTH_SYSTEM', 'Health System' + AMBULATORY = 'AMBULATORY', 'Ambulatory Care' + SPECIALTY = 'SPECIALTY', 'Specialty Practice' + URGENT_CARE = 'URGENT_CARE', 'Urgent Care' + REHABILITATION = 'REHABILITATION', 'Rehabilitation Center' + LONG_TERM_CARE = 'LONG_TERM_CARE', 'Long-term Care' + + class SubscriptionPlan(models.TextChoices): + BASIC = 'BASIC', 'Basic' + STANDARD = 'STANDARD', 'Standard' + PREMIUM = 'PREMIUM', 'Premium' + ENTERPRISE = 'ENTERPRISE', 'Enterprise' - SUBSCRIPTION_PLAN_CHOICES = [ - ('BASIC', 'Basic'), - ('STANDARD', 'Standard'), - ('PREMIUM', 'Premium'), - ('ENTERPRISE', 'Enterprise'), - ] # Tenant Information tenant_id = models.UUIDField( default=uuid.uuid4, @@ -61,8 +60,8 @@ class Tenant(models.Model): # Organization Details organization_type = models.CharField( max_length=50, - choices=ORGANIZATION_TYPE_CHOICES, - default='HOSPITAL' + choices=OrganizationType.choices, + default=OrganizationType.HOSPITAL ) # Contact Information @@ -157,8 +156,8 @@ class Tenant(models.Model): # Subscription and Billing subscription_plan = models.CharField( max_length=50, - choices=SUBSCRIPTION_PLAN_CHOICES, - default='BASIC' + choices=SubscriptionPlan.choices, + default=SubscriptionPlan.BASIC ) max_users = models.PositiveIntegerField( default=50, @@ -220,42 +219,40 @@ class AuditLogEntry(models.Model): Comprehensive audit logging for HIPAA/GDPR compliance. Tracks all user actions and system events. """ - RISK_LEVEL_CHOICES = [ - ('LOW', 'Low'), - ('MEDIUM', 'Medium'), - ('HIGH', 'High'), - ('CRITICAL', 'Critical'), - ] - EVENT_TYPE_CHOICES = [ - ('CREATE', 'Create'), - ('READ', 'Read'), - ('UPDATE', 'Update'), - ('DELETE', 'Delete'), - ('LOGIN', 'Login'), - ('LOGOUT', 'Logout'), - ('ACCESS', 'Access'), - ('EXPORT', 'Export'), - ('PRINT', 'Print'), - ('SHARE', 'Share'), - ('SYSTEM', 'System Event'), - ('ERROR', 'Error'), - ('SECURITY', 'Security Event'), - ] + class RiskLevel(models.TextChoices): + LOW = 'LOW', 'Low' + MEDIUM = 'MEDIUM', 'Medium' + HIGH = 'HIGH', 'High' + CRITICAL = 'CRITICAL', 'Critical' - EVENT_CATEGORY_CHOICES = [ - ('AUTHENTICATION', 'Authentication'), - ('AUTHORIZATION', 'Authorization'), - ('DATA_ACCESS', 'Data Access'), - ('DATA_MODIFICATION', 'Data Modification'), - ('SYSTEM_ADMINISTRATION', 'System Administration'), - ('PATIENT_DATA', 'Patient Data'), - ('CLINICAL_DATA', 'Clinical Data'), - ('FINANCIAL_DATA', 'Financial Data'), - ('SECURITY', 'Security'), - ('INTEGRATION', 'Integration'), - ('REPORTING', 'Reporting'), - ] + class EventType(models.TextChoices): + CREATE = 'CREATE', 'Create' + READ = 'READ', 'Read' + UPDATE = 'UPDATE', 'Update' + DELETE = 'DELETE', 'Delete' + LOGIN = 'LOGIN', 'Login' + LOGOUT = 'LOGOUT', 'Logout' + ACCESS = 'ACCESS', 'Access' + EXPORT = 'EXPORT', 'Export' + PRINT = 'PRINT', 'Print' + SHARE = 'SHARE', 'Share' + SYSTEM = 'SYSTEM', 'System Event' + ERROR = 'ERROR', 'Error' + SECURITY = 'SECURITY', 'Security Event' + + class EventCategory(models.TextChoices): + AUTHENTICATION = 'AUTHENTICATION', 'Authentication' + AUTHORIZATION = 'AUTHORIZATION', 'Authorization' + DATA_ACCESS = 'DATA_ACCESS', 'Data Access' + DATA_MODIFICATION = 'DATA_MODIFICATION', 'Data Modification' + SYSTEM_ADMINISTRATION = 'SYSTEM_ADMINISTRATION', 'System Administration' + PATIENT_DATA = 'PATIENT_DATA', 'Patient Data' + CLINICAL_DATA = 'CLINICAL_DATA', 'Clinical Data' + FINANCIAL_DATA = 'FINANCIAL_DATA', 'Financial Data' + SECURITY = 'SECURITY', 'Security' + INTEGRATION = 'INTEGRATION', 'Integration' + REPORTING = 'REPORTING', 'Reporting' # Tenant tenant = models.ForeignKey( @@ -275,11 +272,11 @@ class AuditLogEntry(models.Model): # Event Information event_type = models.CharField( max_length=50, - choices=EVENT_TYPE_CHOICES + choices=EventType.choices, ) event_category = models.CharField( max_length=50, - choices=EVENT_CATEGORY_CHOICES + choices=EventCategory.choices, ) # User Information @@ -375,8 +372,8 @@ class AuditLogEntry(models.Model): # Risk Assessment risk_level = models.CharField( max_length=20, - choices=RISK_LEVEL_CHOICES, - default='LOW' + choices=RiskLevel.choices, + default=RiskLevel.LOW ) # Compliance Flags @@ -428,15 +425,16 @@ class SystemConfiguration(models.Model): """ System configuration settings for tenant-specific and global configurations. """ - DATA_TYPE_CHOICES = [ - ('STRING', 'String'), - ('INTEGER', 'Integer'), - ('FLOAT', 'Float'), - ('BOOLEAN', 'Boolean'), - ('JSON', 'JSON'), - ('DATE', 'Date'), - ('DATETIME', 'DateTime'), - ] + + class DataType(models.TextChoices): + STRING = 'STRING', 'String' + INTEGER = 'INTEGER', 'Integer' + FLOAT = 'FLOAT', 'Float' + BOOLEAN = 'BOOLEAN', 'Boolean' + JSON = 'JSON', 'JSON' + DATE = 'DATE', 'Date' + DATETIME = 'DATETIME', 'DateTime' + # Tenant (null for global configurations) tenant = models.ForeignKey( Tenant, @@ -456,8 +454,8 @@ class SystemConfiguration(models.Model): ) data_type = models.CharField( max_length=20, - choices=DATA_TYPE_CHOICES, - default='STRING' + choices=DataType.choices, + default=DataType.STRING ) # Configuration Metadata @@ -555,32 +553,30 @@ class SystemNotification(models.Model): """ System-wide notifications and announcements. """ - TARGET_AUDIENCE = [ - ('ALL_USERS', 'All Users'), - ('ADMINISTRATORS', 'Administrators'), - ('CLINICAL_STAFF', 'Clinical Staff'), - ('SUPPORT_STAFF', 'Support Staff'), - ('SPECIFIC_ROLES', 'Specific Roles'), - ('SPECIFIC_USERS', 'Specific Users'), - ] - NOTIFICATION_TYPE_CHOICES = [ - ('INFO', 'Information'), - ('WARNING', 'Warning'), - ('ERROR', 'Error'), - ('SUCCESS', 'Success'), - ('MAINTENANCE', 'Maintenance'), - ('SECURITY', 'Security Alert'), - ('FEATURE', 'New Feature'), - ('UPDATE', 'System Update'), - ] + class TargetAudience(models.TextChoices): + ALL_USERS = 'ALL_USERS', 'All Users' + ADMINISTRATORS = 'ADMINISTRATORS', 'Administrators' + CLINICAL_STAFF = 'CLINICAL_STAFF', 'Clinical Staff' + SUPPORT_STAFF = 'SUPPORT_STAFF', 'Support Staff' + SPECIFIC_ROLES = 'SPECIFIC_ROLES', 'Specific Roles' + SPECIFIC_USERS = 'SPECIFIC_USERS', 'Specific Users' - PRIORITY_CHOICES = [ - ('LOW', 'Low'), - ('MEDIUM', 'Medium'), - ('HIGH', 'High'), - ('URGENT', 'Urgent'), - ] + class NotificationType(models.TextChoices): + INFO = 'INFO', 'Information' + WARNING = 'WARNING', 'Warning' + ERROR = 'ERROR', 'Error' + SUCCESS = 'SUCCESS', 'Success' + MAINTENANCE = 'MAINTENANCE', 'Maintenance' + SECURITY = 'SECURITY', 'Security Alert' + FEATURE = 'FEATURE', 'New Feature' + UPDATE = 'UPDATE', 'System Update' + + class NotificationPriority(models.TextChoices): + LOW = 'LOW', 'Low' + MEDIUM = 'MEDIUM', 'Medium' + HIGH = 'HIGH', 'High' + URGENT = 'URGENT', 'Urgent' # Tenant (null for global notifications) tenant = models.ForeignKey( @@ -609,21 +605,21 @@ class SystemNotification(models.Model): # Notification Type notification_type = models.CharField( max_length=30, - choices=NOTIFICATION_TYPE_CHOICES, + choices=NotificationType.choices, ) # Priority priority = models.CharField( max_length=20, - choices=PRIORITY_CHOICES, - default='MEDIUM' + choices=NotificationPriority.choices, + default=NotificationPriority.MEDIUM ) # Targeting target_audience = models.CharField( max_length=30, - choices=TARGET_AUDIENCE, - default='ALL_USERS' + choices=TargetAudience.choices, + default=TargetAudience.ALL_USERS ) target_roles = models.JSONField( default=list, @@ -715,24 +711,23 @@ class IntegrationLog(models.Model): """ Integration logging for external system communications. """ - STATUS_CHOICES = [ - ('SUCCESS', 'Success'), - ('FAILED', 'Failed'), - ('PENDING', 'Pending'), - ('TIMEOUT', 'Timeout'), - ('RETRY', 'Retry'), - ] - INTEGRATION_TYPE_CHOICES = [ - ('HL7', 'HL7 Message'), - ('DICOM', 'DICOM Communication'), - ('API', 'API Call'), - ('DATABASE', 'Database Sync'), - ('FILE_TRANSFER', 'File Transfer'), - ('WEBHOOK', 'Webhook'), - ('EMAIL', 'Email'), - ('SMS', 'SMS'), - ] + class IntegrationStatus(models.TextChoices): + SUCCESS = 'SUCCESS', 'Success' + FAILED = 'FAILED', 'Failed' + PENDING = 'PENDING', 'Pending' + TIMEOUT = 'TIMEOUT', 'Timeout' + RETRY = 'RETRY', 'Retry' + + class IntegrationType(models.TextChoices): + HL7 = 'HL7', 'HL7 Message' + DICOM = 'DICOM', 'DICOM Communication' + API = 'API', 'API Call' + DATABASE = 'DATABASE', 'Database Sync' + FILE_TRANSFER = 'FILE_TRANSFER', 'File Transfer' + WEBHOOK = 'WEBHOOK', 'Webhook' + EMAIL = 'EMAIL', 'Email' + SMS = 'SMS', 'SMS' # Tenant tenant = models.ForeignKey( @@ -752,7 +747,7 @@ class IntegrationLog(models.Model): # Integration Information integration_type = models.CharField( max_length=30, - choices=INTEGRATION_TYPE_CHOICES + choices=IntegrationType.choices, ) direction = models.CharField( max_length=10, @@ -808,7 +803,7 @@ class IntegrationLog(models.Model): # Status status = models.CharField( max_length=20, - choices=STATUS_CHOICES, + choices=IntegrationStatus.choices, ) # Error Information diff --git a/core/views.py b/core/views.py index 47835168..a13d556d 100644 --- a/core/views.py +++ b/core/views.py @@ -42,8 +42,8 @@ class DashboardView(LoginRequiredMixin, TemplateView): template_name = 'core/dashboard.html' def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) tenant = self.request.user.tenant + context = super().get_context_data(**kwargs) if tenant: # Get dashboard statistics @@ -233,7 +233,7 @@ class AuditLogListView(LoginRequiredMixin, ListView): paginate_by = 50 def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return AuditLogEntry.objects.none() @@ -264,7 +264,7 @@ class AuditLogListView(LoginRequiredMixin, ListView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if tenant: # Get filter options @@ -289,7 +289,7 @@ class AuditLogDetailView(LoginRequiredMixin, DetailView): context_object_name = 'audit_log' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return AuditLogEntry.objects.none() return AuditLogEntry.objects.filter(tenant=tenant) @@ -304,7 +304,7 @@ class SystemConfigurationListView(LoginRequiredMixin, ListView): context_object_name = 'configurations' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant queryset = SystemConfiguration.objects.filter( Q(tenant=tenant) | Q(tenant=None), is_active=True @@ -328,7 +328,7 @@ class SystemConfigurationListView(LoginRequiredMixin, ListView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if tenant: # Group configurations by category @@ -359,7 +359,7 @@ class SystemConfigurationDetailView(LoginRequiredMixin, DetailView): context_object_name = 'configuration' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant return SystemConfiguration.objects.filter( Q(tenant=tenant) | Q(tenant=None) ) @@ -377,7 +377,7 @@ class SystemConfigurationCreateView(LoginRequiredMixin, PermissionRequiredMixin, def form_valid(self, form): # Set tenant - form.instance.tenant = getattr(self.request, 'tenant', None) + form.instance.tenant = self.request.user.tenant response = super().form_valid(form) # Log configuration creation @@ -406,7 +406,7 @@ class SystemConfigurationUpdateView(LoginRequiredMixin, PermissionRequiredMixin, permission_required = 'core.change_systemconfiguration' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant return SystemConfiguration.objects.filter( Q(tenant=tenant) | Q(tenant=None) ) @@ -443,7 +443,7 @@ class SystemConfigurationDeleteView(LoginRequiredMixin, PermissionRequiredMixin, success_url = reverse_lazy('core:system_configuration_list') def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant return SystemConfiguration.objects.filter(tenant=tenant) # Only tenant-specific configs can be deleted def delete(self, request, *args, **kwargs): @@ -475,7 +475,7 @@ class SystemNotificationListView(LoginRequiredMixin, ListView): paginate_by = 20 def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant queryset = SystemNotification.objects.filter( Q(tenant=tenant) | Q(tenant=None) ).order_by('-created_at') @@ -515,7 +515,7 @@ class SystemNotificationDetailView(LoginRequiredMixin, DetailView): context_object_name = 'notification' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant return SystemNotification.objects.filter( Q(tenant=tenant) | Q(tenant=None) ) @@ -533,7 +533,7 @@ class SystemNotificationCreateView(LoginRequiredMixin, PermissionRequiredMixin, def form_valid(self, form): # Set tenant and creator - form.instance.tenant = getattr(self.request, 'tenant', None) + form.instance.tenant = self.request.user.tenant form.instance.created_by = self.request.user response = super().form_valid(form) @@ -563,7 +563,7 @@ class SystemNotificationUpdateView(LoginRequiredMixin, PermissionRequiredMixin, permission_required = 'core.change_systemnotification' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant return SystemNotification.objects.filter( Q(tenant=tenant) | Q(tenant=None) ) @@ -600,7 +600,7 @@ class SystemNotificationDeleteView(LoginRequiredMixin, PermissionRequiredMixin, success_url = reverse_lazy('core:notification_list') def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant return SystemNotification.objects.filter(tenant=tenant) # Only tenant notifications can be deleted def delete(self, request, *args, **kwargs): @@ -632,7 +632,7 @@ class IntegrationLogListView(LoginRequiredMixin, ListView): paginate_by = 50 def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return IntegrationLog.objects.none() @@ -659,7 +659,7 @@ class IntegrationLogListView(LoginRequiredMixin, ListView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if tenant: context.update({ @@ -683,7 +683,7 @@ class IntegrationLogDetailView(LoginRequiredMixin, DetailView): context_object_name = 'log' def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return IntegrationLog.objects.none() return IntegrationLog.objects.filter(tenant=tenant) @@ -694,7 +694,7 @@ def dashboard_stats(request): """ HTMX view for dashboard statistics. """ - tenant = getattr(request, 'tenant', None) + tenant = request.user.tenant if not tenant: return JsonResponse({'error': 'No tenant found'}, status=400) @@ -735,7 +735,7 @@ def audit_log_search(request): """ HTMX view for audit log search. """ - tenant = getattr(request, 'tenant', None) + tenant = request.user.tenant if not tenant: return JsonResponse({'error': 'No tenant found'}, status=400) @@ -762,7 +762,7 @@ def system_notifications(request): """ HTMX view for system notifications. """ - tenant = getattr(request, 'tenant', None) + tenant = request.user.tenant if not tenant: return JsonResponse({'error': 'No tenant found'}, status=400) @@ -785,7 +785,7 @@ def dismiss_notification(request, notification_id): """ HTMX view to dismiss a notification. """ - tenant = getattr(request, 'tenant', None) + tenant = request.user.tenant if not tenant: return JsonResponse({'error': 'No tenant found'}, status=400) @@ -815,7 +815,7 @@ def tenant_info(request): """ HTMX view for tenant information. """ - tenant = getattr(request, 'tenant', None) + tenant = request.user.tenant if not tenant: return JsonResponse({'error': 'No tenant found'}, status=400) @@ -829,7 +829,7 @@ def system_health(request): """ HTMX view for system health status. """ - tenant = getattr(request, 'tenant', None) + tenant = request.user.tenant if not tenant: return JsonResponse({'error': 'No tenant found'}, status=400) @@ -867,7 +867,7 @@ def activate_notification(request, pk): """ Activate a system notification. """ - tenant = getattr(request, 'tenant', None) + tenant = request.user.tenant if not tenant: messages.error(request, 'No tenant found.') return redirect('core:notification_list') @@ -902,7 +902,7 @@ def deactivate_notification(request, pk): """ Deactivate a system notification. """ - tenant = getattr(request, 'tenant', None) + tenant = request.user.tenant if not tenant: messages.error(request, 'No tenant found.') return redirect('core:notification_list') @@ -937,7 +937,7 @@ def reset_configuration(request, pk): """ Reset configuration to default value. """ - tenant = getattr(request, 'tenant', None) + tenant = request.user.tenant if not tenant: messages.error(request, 'No tenant found.') return redirect('core:system_configuration_list') @@ -969,12 +969,12 @@ def reset_configuration(request, pk): return redirect('core:system_configuration_detail', pk=pk) +@login_required def tenant_stats(request): """ HTMX view for tenant statistics. """ - from django.db.models import Count - + stats = { 'total_tenants': Tenant.objects.count(), 'active_tenants': Tenant.objects.filter(is_active=True).count(), @@ -985,6 +985,7 @@ def tenant_stats(request): return render(request, 'core/partials/tenant_stats.html', {'stats': stats}) +@login_required def configuration_search(request): """ HTMX view for configuration search. @@ -1004,12 +1005,14 @@ def configuration_search(request): }) +@login_required def audit_log_list_htmx(request): """ HTMX view for audit log list. """ + tenant = request.user.tenant logs = AuditLog.objects.filter( - tenant=request.user.tenant + tenant=tenant ).order_by('-timestamp')[:20] return render(request, 'core/partials/audit_log_list.html', { @@ -1017,11 +1020,12 @@ def audit_log_list_htmx(request): }) +@login_required def activate_tenant(request, pk): """ Activate a tenant. """ - tenant = get_object_or_404(Tenant, tenant_id=pk) + tenant = get_object_or_404(Tenant, pk=pk) tenant.is_active = True tenant.save() @@ -1029,11 +1033,12 @@ def activate_tenant(request, pk): return redirect('core:tenant_detail', pk=pk) +@login_required def deactivate_tenant(request, pk): """ Deactivate a tenant. """ - tenant = get_object_or_404(Tenant, tenant_id=pk) + tenant = get_object_or_404(Tenant, pk=pk) tenant.is_active = False tenant.save() @@ -1041,14 +1046,16 @@ def deactivate_tenant(request, pk): return redirect('core:tenant_detail', pk=pk) +@login_required def reset_system_configuration(request): """ Reset system configuration to defaults. """ + tenant = request.user.tenant if request.method == 'POST': # Reset configurations for the tenant SystemConfiguration.objects.filter( - tenant=request.user.tenant + tenant=tenant ).delete() messages.success(request, 'System configuration has been reset to defaults.') @@ -1057,13 +1064,12 @@ def reset_system_configuration(request): return render(request, 'core/configurations/reset_configuration_confirm.html') +@login_required def export_audit_log(request): """ Export audit log to CSV. """ - import csv - from django.http import HttpResponse - + tenant = request.user.tenant response = HttpResponse(content_type='text/csv') response['Content-Disposition'] = 'attachment; filename="audit_log.csv"' @@ -1071,7 +1077,7 @@ def export_audit_log(request): writer.writerow(['Timestamp', 'User', 'Action', 'Object Type', 'Object ID', 'Changes']) logs = AuditLog.objects.filter( - tenant=request.user.tenant + tenant=tenant ).order_by('-timestamp') for log in logs: @@ -1087,7 +1093,7 @@ def export_audit_log(request): return response -class CoreSearchView(ListView): +class CoreSearchView(LoginRequiredMixin, ListView): """ Generic search view for core models. """ @@ -1132,6 +1138,7 @@ class CoreSearchView(ListView): return context +@login_required def tenant_search(request): """ AJAX search for tenants. @@ -1147,6 +1154,7 @@ def tenant_search(request): return JsonResponse({'tenants': list(tenants)}) +@login_required def bulk_activate_tenants(request): """ Bulk activate tenants. @@ -1162,6 +1170,7 @@ def bulk_activate_tenants(request): return redirect('core:tenant_list') +@login_required def bulk_deactivate_tenants(request): """ Bulk deactivate tenants. @@ -1177,12 +1186,12 @@ def bulk_deactivate_tenants(request): return redirect('core:tenant_list') +@login_required def bulk_export_audit_logs(request): """ Bulk export audit logs. """ - import csv - from django.http import HttpResponse + tenant = request.user.tenant if request.method == 'POST': log_ids = request.POST.getlist('log_ids') @@ -1194,7 +1203,7 @@ def bulk_export_audit_logs(request): writer.writerow(['Timestamp', 'User', 'Action', 'Object Type', 'Object ID', 'Changes']) logs = AuditLog.objects.filter( - tenant=request.user.tenant, + tenant=tenant, log_id__in=log_ids ).order_by('-timestamp') @@ -1213,6 +1222,7 @@ def bulk_export_audit_logs(request): return redirect('core:audit_log_list') +@login_required def validate_tenant_data(request): """ AJAX validation for tenant data. @@ -1229,6 +1239,7 @@ def validate_tenant_data(request): return JsonResponse({'valid': len(errors) == 0, 'errors': errors}) +@login_required def get_system_status(request): """ Get system status information. @@ -1254,19 +1265,19 @@ def get_system_status(request): return JsonResponse(status) +@login_required def backup_configuration(request): """ Backup system configuration. """ - import json - from django.http import HttpResponse + tenant = request.user.tenant configurations = SystemConfiguration.objects.filter( - tenant=request.user.tenant + tenant=tenant ).values('key', 'value', 'description') backup_data = { - 'tenant': str(request.user.tenant.tenant_id), + 'tenant': str(tenant.tenant_id), 'timestamp': timezone.now().isoformat(), 'configurations': list(configurations) } @@ -1280,11 +1291,12 @@ def backup_configuration(request): return response +@login_required def restore_configuration(request): """ Restore system configuration from backup. """ - import json + tenant = request.user.tenant if request.method == 'POST': backup_file = request.FILES.get('backup_file') @@ -1295,13 +1307,13 @@ def restore_configuration(request): # Clear existing configurations SystemConfiguration.objects.filter( - tenant=request.user.tenant + tenant=tenant ).delete() # Restore configurations for config in backup_data.get('configurations', []): SystemConfiguration.objects.create( - tenant=request.user.tenant, + tenant=tenant, key=config['key'], value=config['value'], description=config.get('description', '') @@ -1318,6 +1330,78 @@ def restore_configuration(request): return render(request, 'core/restore_configuration.html') +@login_required +def notification_search(request): + """API endpoint for searching notifications.""" + user = request.user + tenant = getattr(user, 'tenant', None) + + # Get search parameters + search_term = request.GET.get('term', '') + notification_type = request.GET.get('notification_type', '') + is_active = request.GET.get('is_active', '') + + # Base query + if user.is_superuser: + query = SystemNotification.objects.all() + elif tenant: + query = SystemNotification.objects.filter( + Q(tenant=tenant) | Q(tenant__isnull=True) + ) + else: + query = SystemNotification.objects.filter(tenant__isnull=True) + + # Apply filters + if search_term: + query = query.filter( + Q(title__icontains=search_term) | + Q(message__icontains=search_term) + ) + + if notification_type: + query = query.filter(notification_type=notification_type) + + if is_active: + is_active_bool = is_active.lower() == 'true' + query = query.filter(is_active=is_active_bool) + + # Only include currently visible notifications + if request.GET.get('visible_only', '') == 'true': + now = timezone.now() + query = query.filter( + is_active=True, + start_date__lte=now, + ) + + # Get results + results = query.order_by('-start_date')[:20] + + # Format results + formatted_results = [] + for notification in results: + # Check if this notification should be visible to the user + if request.GET.get('check_visibility', '') == 'true': + if not notification.is_visible_to_user(user): + continue + + formatted_results.append({ + 'id': str(notification.notification_id), + 'title': notification.title, + 'message': notification.message[:100] + '...' if len(notification.message) > 100 else notification.message, + 'type': notification.notification_type, + 'type_display': notification.get_notification_type_display(), + 'is_active': notification.is_active, + 'is_dismissible': notification.is_dismissible, + 'start_date': notification.start_date.isoformat(), + 'end_date': notification.end_date.isoformat() if notification.end_date else None, + 'url': reverse('core:system_notification_detail', args=[notification.notification_id]) + }) + + return JsonResponse({ + 'status': 'success', + 'results': formatted_results, + 'count': len(formatted_results) + }) @@ -1333,7 +1417,7 @@ def restore_configuration(request): # paginate_by = 20 # # def get_queryset(self): -# tenant = getattr(self.request, 'tenant', None) +# tenant = self.request.user.tenant # if not tenant: # return Department.objects.none() # @@ -1365,7 +1449,7 @@ def restore_configuration(request): # context_object_name = 'department' # # def get_queryset(self): -# tenant = getattr(self.request, 'tenant', None) +# tenant = self.request.user.tenant # if not tenant: # return Department.objects.none() # return Department.objects.filter(tenant=tenant) @@ -1399,7 +1483,7 @@ def restore_configuration(request): # # def form_valid(self, form): # # Set tenant -# form.instance.tenant = getattr(self.request, 'tenant', None) +# form.instance.tenant = self.request.user.tenant # response = super().form_valid(form) # # # Log department creation @@ -1428,7 +1512,7 @@ def restore_configuration(request): # permission_required = 'core.change_department' # # def get_queryset(self): -# tenant = getattr(self.request, 'tenant', None) +# tenant = self.request.user.tenant # if not tenant: # return Department.objects.none() # return Department.objects.filter(tenant=tenant) @@ -1465,7 +1549,7 @@ def restore_configuration(request): # success_url = reverse_lazy('core:department_list') # # def get_queryset(self): -# tenant = getattr(self.request, 'tenant', None) +# tenant = self.request.user.tenant # if not tenant: # return Department.objects.none() # return Department.objects.filter(tenant=tenant) @@ -3442,82 +3526,11 @@ def restore_configuration(request): # 'status': 'error', # 'message': _('Configuration not found.') # }, status=404) -# -# -# @login_required -# def notification_search(request): -# """API endpoint for searching notifications.""" -# user = request.user -# tenant = getattr(user, 'tenant', None) -# -# # Get search parameters -# search_term = request.GET.get('term', '') -# notification_type = request.GET.get('notification_type', '') -# is_active = request.GET.get('is_active', '') -# -# # Base query -# if user.is_superuser: -# query = SystemNotification.objects.all() -# elif tenant: -# query = SystemNotification.objects.filter( -# Q(tenant=tenant) | Q(tenant__isnull=True) -# ) -# else: -# query = SystemNotification.objects.filter(tenant__isnull=True) -# -# # Apply filters -# if search_term: -# query = query.filter( -# Q(title__icontains=search_term) | -# Q(message__icontains=search_term) -# ) -# -# if notification_type: -# query = query.filter(notification_type=notification_type) -# -# if is_active: -# is_active_bool = is_active.lower() == 'true' -# query = query.filter(is_active=is_active_bool) -# -# # Only include currently visible notifications -# if request.GET.get('visible_only', '') == 'true': -# now = timezone.now() -# query = query.filter( -# is_active=True, -# start_date__lte=now, -# ) -# -# # Get results -# results = query.order_by('-start_date')[:20] -# -# # Format results -# formatted_results = [] -# for notification in results: -# # Check if this notification should be visible to the user -# if request.GET.get('check_visibility', '') == 'true': -# if not notification.is_visible_to_user(user): -# continue -# -# formatted_results.append({ -# 'id': str(notification.notification_id), -# 'title': notification.title, -# 'message': notification.message[:100] + '...' if len(notification.message) > 100 else notification.message, -# 'type': notification.notification_type, -# 'type_display': notification.get_notification_type_display(), -# 'is_active': notification.is_active, -# 'is_dismissible': notification.is_dismissible, -# 'start_date': notification.start_date.isoformat(), -# 'end_date': notification.end_date.isoformat() if notification.end_date else None, -# 'url': reverse('core:system_notification_detail', args=[notification.notification_id]) -# }) -# -# return JsonResponse({ -# 'status': 'success', -# 'results': formatted_results, -# 'count': len(formatted_results) -# }) -# -# + + + + + # @login_required # def department_search(request): # """API endpoint for searching departments.""" diff --git a/core_data.py b/core_data.py index 3a154b9e..9f5be364 100644 --- a/core_data.py +++ b/core_data.py @@ -334,7 +334,7 @@ def create_saudi_audit_logs(tenants, count_per_tenant=100): 'vital_signs_recorded', 'medication_administered' ] - risk_levels = AuditLogEntry.RISK_LEVEL_CHOICES + risk_levels = AuditLogEntry.RiskLevel.choices for tenant in tenants: for _ in range(count_per_tenant): @@ -380,7 +380,7 @@ def create_saudi_integration_logs(tenants, count_per_tenant=50): """Create Saudi-specific integration logs""" integration_logs = [] - saudi_integrations = IntegrationLog.INTEGRATION_TYPE_CHOICES + saudi_integrations = IntegrationLog.IntegrationType.choices message_types = [ 'patient_eligibility', 'claim_submission', 'payment_notification', diff --git a/data_utils/__init__.py b/data_utils/__init__.py new file mode 100644 index 00000000..b46bfda9 --- /dev/null +++ b/data_utils/__init__.py @@ -0,0 +1,6 @@ +""" +Shared utilities for data generation scripts. +Provides common constants, generators, and helper functions. +""" + +__version__ = "1.0.0" diff --git a/data_utils/__pycache__/__init__.cpython-312.pyc b/data_utils/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..10e3256b Binary files /dev/null and b/data_utils/__pycache__/__init__.cpython-312.pyc differ diff --git a/data_utils/__pycache__/base.cpython-312.pyc b/data_utils/__pycache__/base.cpython-312.pyc new file mode 100644 index 00000000..6793517e Binary files /dev/null and b/data_utils/__pycache__/base.cpython-312.pyc differ diff --git a/data_utils/__pycache__/constants.cpython-312.pyc b/data_utils/__pycache__/constants.cpython-312.pyc new file mode 100644 index 00000000..ef2700c7 Binary files /dev/null and b/data_utils/__pycache__/constants.cpython-312.pyc differ diff --git a/data_utils/__pycache__/generators.cpython-312.pyc b/data_utils/__pycache__/generators.cpython-312.pyc new file mode 100644 index 00000000..93506d3c Binary files /dev/null and b/data_utils/__pycache__/generators.cpython-312.pyc differ diff --git a/data_utils/__pycache__/helpers.cpython-312.pyc b/data_utils/__pycache__/helpers.cpython-312.pyc new file mode 100644 index 00000000..06ff40b5 Binary files /dev/null and b/data_utils/__pycache__/helpers.cpython-312.pyc differ diff --git a/data_utils/base.py b/data_utils/base.py new file mode 100644 index 00000000..0a51f45a --- /dev/null +++ b/data_utils/base.py @@ -0,0 +1,271 @@ +""" +Base data generator class for Saudi healthcare data. +Provides common functionality and patterns for all data generators. +""" + +import os +import django +from datetime import datetime, timedelta +from django.utils import timezone as django_timezone +from django.db import transaction + +from .constants import * +from .generators import * +from .helpers import * + + +class BaseDataGenerator: + """Base class for Saudi healthcare data generators""" + + def __init__(self, tenant=None): + """Initialize the data generator""" + self.tenant = tenant + self.progress_tracker = ProgressTracker() + + def setup_django(self): + """Setup Django environment if not already done""" + if not hasattr(django, 'apps') or not django.apps.apps_ready: + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'hospital_management.settings') + django.setup() + + def validate_dependencies(self): + """Validate that required dependencies exist""" + return validate_dependencies() + + def get_tenants(self): + """Get tenants for data generation""" + from core.models import Tenant + + if self.tenant: + return [self.tenant] + + tenants = list(Tenant.objects.filter(is_active=True)) + if not tenants: + raise ValueError("No active tenants found") + + return tenants + + def get_tenant_data(self, tenant): + """Get common tenant data needed for generation""" + return { + 'users': get_tenant_users(tenant), + 'providers': get_tenant_providers(tenant), + 'patients': get_tenant_patients(tenant), + 'departments': get_tenant_departments(tenant) + } + + def safe_bulk_create(self, Model, objects, **kwargs): + """Safe bulk create with error handling""" + return safe_bulk_create(Model, objects, **kwargs) + + def safe_create(self, Model, **kwargs): + """Safe create with error handling""" + return safe_create(Model, **kwargs) + + def print_progress(self, message=""): + """Print current progress""" + self.progress_tracker.print_progress(message) + + def increment_progress(self, count=1): + """Increment progress counter""" + self.progress_tracker.increment(count) + + def generate_summary(self, results): + """Generate summary of generation results""" + total_created = sum(results.values()) if isinstance(results, dict) else len(results) + return { + 'total_created': total_created, + 'details': results, + 'timestamp': django_timezone.now(), + 'tenant': self.tenant.name if self.tenant else 'All Tenants' + } + + def run_generation(self, **kwargs): + """Main generation method - to be implemented by subclasses""" + raise NotImplementedError("Subclasses must implement run_generation method") + + +class SaudiHealthcareDataGenerator(BaseDataGenerator): + """Base class for Saudi healthcare data generators with common Saudi-specific functionality""" + + def __init__(self, tenant=None): + super().__init__(tenant) + self.setup_django() + + def generate_saudi_name(self, gender=None): + """Generate a Saudi name""" + return generate_saudi_name(gender) + + def generate_saudi_address(self): + """Generate a Saudi address""" + return generate_saudi_address() + + def generate_saudi_phone(self): + """Generate a Saudi phone number""" + return generate_saudi_phone() + + def generate_saudi_mobile(self): + """Generate a Saudi mobile number""" + return generate_saudi_mobile_e164() + + def generate_birth_date(self, min_age=1, max_age=85): + """Generate birth date within age range""" + return generate_birth_date(min_age, max_age) + + def generate_hire_date(self, max_years_ago=20): + """Generate hire date within reasonable range""" + return generate_hire_date(max_years_ago) + + def generate_future_date(self, days_ahead=365): + """Generate future date""" + return generate_future_date(days_ahead) + + def generate_past_date(self, days_back=365): + """Generate past date""" + return generate_past_date(days_back) + + def generate_vital_signs(self, patient_age=None): + """Generate realistic vital signs""" + return generate_vital_signs(patient_age) + + def pick_job_title(self, department): + """Pick appropriate job title for department""" + return pick_job_title_for_department(department) + + def infer_role(self, job_title): + """Infer role from job title""" + return infer_role_from_title(job_title) + + def tenant_unique_username(self, tenant, base_username): + """Generate tenant-unique username""" + return tenant_scoped_unique_username(tenant, base_username) + + def generate_lab_values(self, test_type='QUANTITATIVE', reference_range=None): + """Generate lab values""" + return generate_lab_values(test_type, reference_range) + + +class DataGenerationOrchestrator: + """Orchestrates the execution of multiple data generators with dependency management""" + + def __init__(self): + self.generators = {} + self.execution_order = [ + 'core', # Tenants + 'accounts', # Users + 'hr', # Employees/Departments + 'patients', # Patients + # Clinical modules (can run in parallel) + 'emr', + 'lab', + 'radiology', + 'pharmacy', + 'appointments', + 'billing', + 'inpatients', + 'inventory', + 'facility_management' + ] + + def register_generator(self, name, generator_class, **kwargs): + """Register a data generator""" + self.generators[name] = { + 'class': generator_class, + 'kwargs': kwargs, + 'instance': None + } + + def get_dependencies(self, generator_name): + """Get dependencies for a generator""" + dependencies = { + 'core': [], + 'accounts': ['core'], + 'hr': ['core', 'accounts'], + 'patients': ['core'], + 'emr': ['core', 'accounts', 'hr', 'patients'], + 'lab': ['core', 'accounts', 'hr', 'patients'], + 'radiology': ['core', 'accounts', 'hr', 'patients'], + 'pharmacy': ['core', 'accounts', 'hr', 'patients'], + 'appointments': ['core', 'accounts', 'hr', 'patients'], + 'billing': ['core', 'accounts', 'patients'], + 'inpatients': ['core', 'accounts', 'hr', 'patients'], + 'inventory': ['core'], + 'facility_management': ['core'] + } + return dependencies.get(generator_name, []) + + def validate_dependencies(self, generator_name, completed_generators): + """Validate that dependencies are satisfied""" + dependencies = self.get_dependencies(generator_name) + missing = [dep for dep in dependencies if dep not in completed_generators] + return len(missing) == 0, missing + + def run_generator(self, name, **kwargs): + """Run a specific generator""" + if name not in self.generators: + print(f"Generator {name} not registered") + return None + + generator_config = self.generators[name] + generator_class = generator_config['class'] + generator_kwargs = {**generator_config['kwargs'], **kwargs} + + try: + generator = generator_class(**generator_kwargs) + results = generator.run_generation() + return results + except Exception as e: + print(f"Error running generator {name}: {e}") + return None + + def run_all(self, generators_to_run=None, **kwargs): + """Run all generators in dependency order""" + if generators_to_run is None: + generators_to_run = self.execution_order + + completed = set() + results = {} + + for generator_name in generators_to_run: + if generator_name not in self.generators: + print(f"Skipping {generator_name} - not registered") + continue + + # Validate dependencies + valid, missing = self.validate_dependencies(generator_name, completed) + if not valid: + print(f"Skipping {generator_name} - missing dependencies: {missing}") + continue + + print(f"\n🚀 Running {generator_name} generator...") + result = self.run_generator(generator_name, **kwargs) + + if result is not None: + results[generator_name] = result + completed.add(generator_name) + print(f"✅ {generator_name} completed") + else: + print(f"❌ {generator_name} failed") + + return results + + def get_available_generators(self): + """Get list of available generators""" + return list(self.generators.keys()) + + def get_execution_plan(self, generators_to_run=None): + """Get execution plan with dependencies""" + if generators_to_run is None: + generators_to_run = self.execution_order + + plan = [] + for generator_name in generators_to_run: + if generator_name in self.generators: + dependencies = self.get_dependencies(generator_name) + plan.append({ + 'name': generator_name, + 'dependencies': dependencies, + 'class': self.generators[generator_name]['class'].__name__ + }) + + return plan diff --git a/data_utils/constants.py b/data_utils/constants.py new file mode 100644 index 00000000..1bea39af --- /dev/null +++ b/data_utils/constants.py @@ -0,0 +1,328 @@ +""" +Shared constants for Saudi healthcare data generation. +Contains all common Saudi-specific data used across multiple modules. +""" + +# ================================ +# SAUDI NAMES AND DEMOGRAPHICS +# ================================ + +SAUDI_FIRST_NAMES_MALE = [ + 'Mohammed', 'Ahmed', 'Abdullah', 'Omar', 'Ali', 'Khalid', 'Fahd', 'Saad', + 'Faisal', 'Saud', 'Abdulrahman', 'Abdulaziz', 'Turki', 'Bandar', 'Nasser', + 'Saad', 'Majed', 'Waleed', 'Yousef', 'Ibrahim', 'Hassan', 'Hussein' +] + +SAUDI_FIRST_NAMES_FEMALE = [ + 'Fatima', 'Aisha', 'Khadija', 'Maryam', 'Zahra', 'Sarah', 'Nora', 'Hala', + 'Reem', 'Layla', 'Amina', 'Nadia', 'Rana', 'Dina', 'Hind', 'Najla', + 'Arwa', 'Ghada', 'Nouf', 'Lama', 'Rania', 'Dana', 'Lina', 'Maha' +] + +SAUDI_FAMILY_NAMES = [ + 'Al-Saud', 'Al-Rashid', 'Al-Mutairi', 'Al-Otaibi', 'Al-Qarni', 'Al-Harbi', + 'Al-Dawsari', 'Al-Subai', 'Al-Sharani', 'Al-Ghamdi', 'Al-Zahrani', 'Al-Maliki', + 'Al-Shehri', 'Al-Qahtani', 'Al-Ansari', 'Al-Hakim', 'Al-Rashidi', 'Al-Bakr' +] + +# ================================ +# SAUDI GEOGRAPHIC DATA +# ================================ + +SAUDI_CITIES = [ + 'Riyadh', 'Jeddah', 'Mecca', 'Medina', 'Dammam', 'Khobar', 'Dhahran', + 'Taif', 'Tabuk', 'Buraidah', 'Khamis Mushait', 'Hail', 'Najran' +] + +SAUDI_PROVINCES = [ + 'Riyadh Province', 'Makkah Province', 'Eastern Province', 'Asir Province', + 'Jazan Province', 'Madinah Province', 'Qassim Province', 'Tabuk Province' +] + +SAUDI_STATES = SAUDI_PROVINCES # Alias for backward compatibility + +# ================================ +# SAUDI MEDICAL DATA +# ================================ + +SAUDI_MEDICAL_SPECIALTIES = [ + 'INTERNAL_MEDICINE', 'CARDIOLOGY', 'ORTHOPEDICS', 'NEUROLOGY', 'ONCOLOGY', + 'PEDIATRICS', 'EMERGENCY_MEDICINE', 'RADIOLOGY', 'LABORATORY_MEDICINE', + 'PHARMACY', 'SURGERY', 'OBSTETRICS_GYNECOLOGY', 'DERMATOLOGY', + 'OPHTHALMOLOGY', 'ENT', 'ANESTHESIOLOGY', 'PATHOLOGY', 'PSYCHIATRY' +] + +SAUDI_DEPARTMENTS = [ + ('EMERGENCY', 'Emergency Department', 'Emergency medical services'), + ('ICU', 'Intensive Care Unit', 'Critical care services'), + ('CARDIOLOGY', 'Cardiology Department', 'Heart and cardiovascular care'), + ('SURGERY', 'General Surgery', 'Surgical services'), + ('ORTHOPEDICS', 'Orthopedics Department', 'Bone and joint care'), + ('PEDIATRICS', 'Pediatrics Department', 'Children healthcare'), + ('OBSTETRICS', 'Obstetrics & Gynecology', 'Women and maternity care'), + ('RADIOLOGY', 'Radiology Department', 'Medical imaging services'), + ('LABORATORY', 'Laboratory Services', 'Diagnostic testing'), + ('PHARMACY', 'Pharmacy Department', 'Medication services'), + ('NURSING', 'Nursing Services', 'Patient care services'), + ('ADMINISTRATION', 'Administration', 'Hospital administration'), + ('FINANCE', 'Finance Department', 'Financial management'), + ('HR', 'Human Resources', 'Staff management'), + ('IT', 'Information Technology', 'Technology services'), + ('MAINTENANCE', 'Maintenance Services', 'Facility maintenance'), + ('SECURITY', 'Security Department', 'Hospital security'), + ('HOUSEKEEPING', 'Housekeeping Services', 'Cleaning services'), + ('FOOD_SERVICE', 'Food Services', 'Dietary services'), + ('SOCIAL_WORK', 'Social Work', 'Patient social services') +] + +SAUDI_JOB_TITLES = { + 'PHYSICIAN': ['Consultant Physician', 'Senior Physician', 'Staff Physician', 'Resident Physician', + 'Chief Medical Officer'], + 'NURSE': ['Head Nurse', 'Senior Nurse', 'Staff Nurse', 'Charge Nurse', 'Clinical Nurse Specialist'], + 'PHARMACIST': ['Clinical Pharmacist', 'Staff Pharmacist', 'Pharmacy Manager', 'Pharmaceutical Consultant'], + 'ADMIN': ['Medical Director', 'Hospital Administrator', 'Department Manager', 'Operations Manager'], + 'LAB_TECH': ['Senior Lab Technician', 'Medical Laboratory Scientist', 'Lab Supervisor'], + 'RAD_TECH': ['Senior Radiologic Technologist', 'CT Technologist', 'MRI Technologist'], + 'RADIOLOGIST': ['Consultant Radiologist', 'Senior Radiologist', 'Interventional Radiologist'], + 'MEDICAL_ASSISTANT': ['Medical Assistant'], + 'CLERICAL': ['Clerical Staff'], +} + +# ================================ +# SAUDI MEDICAL CONSTANTS +# ================================ + +SAUDI_COMMON_DIAGNOSES = [ + 'Diabetes Mellitus Type 2', 'Hypertension', 'Coronary Artery Disease', + 'Chronic Kidney Disease', 'Heart Failure', 'Pneumonia', 'Gastritis', + 'Cholecystitis', 'Appendicitis', 'Urinary Tract Infection', + 'Acute Myocardial Infarction', 'Stroke', 'Pulmonary Embolism', + 'Deep Vein Thrombosis', 'Sepsis', 'Acute Renal Failure', + 'Chronic Obstructive Pulmonary Disease', 'Asthma', 'Anemia' +] + +SAUDI_SURGICAL_PROCEDURES = [ + 'Laparoscopic Cholecystectomy', 'Open Heart Surgery', 'Total Knee Replacement', + 'Inguinal Hernia Repair', 'Appendectomy', 'Diagnostic Laparoscopy', + 'Thyroidectomy', 'Cataract Surgery', 'Tonsillectomy', 'Hemorrhoidectomy' +] + +SAUDI_MEDICATIONS = [ + ('Paracetamol', 'باراسيتامول', 'TAB', 'Analgesic', '500mg', ['J01XX01']), + ('Ibuprofen', 'ايبوبروفين', 'TAB', 'NSAID', '400mg', ['M01AE01']), + ('Metformin', 'ميتفورمين', 'TAB', 'Antidiabetic', '500mg', ['A10BA02']), + ('Amlodipine', 'أملوديبين', 'TAB', 'Antihypertensive', '5mg', ['C08CA01']), + ('Atorvastatin', 'أتورفاستاتين', 'TAB', 'Statin', '20mg', ['C10AA05']), + ('Omeprazole', 'أوميبرازول', 'CAP', 'PPI', '20mg', ['A02BC01']), + ('Salbutamol', 'سالبيوتامول', 'INH', 'Bronchodilator', '100mcg', ['R03AC02']), + ('Warfarin', 'وارفارين', 'TAB', 'Anticoagulant', '5mg', ['B01AA03']), + ('Insulin Glargine', 'انسولين جلارجين', 'INJ', 'Insulin', '100units/ml', ['A10AE04']), + ('Levothyroxine', 'ليفوثيروكسين', 'TAB', 'Thyroid hormone', '100mcg', ['H03AA01']), + ('Losartan', 'لوسارتان', 'TAB', 'ARB', '50mg', ['C09CA01']), + ('Pantoprazole', 'بانتوبرازول', 'TAB', 'PPI', '40mg', ['A02BC02']), + ('Clopidogrel', 'كلوبيدوجريل', 'TAB', 'Antiplatelet', '75mg', ['B01AC04']), + ('Simvastatin', 'سيمفاستاتين', 'TAB', 'Statin', '20mg', ['C10AA01']), + ('Furosemide', 'فوروسيمايد', 'TAB', 'Diuretic', '40mg', ['C03CA01']), + ('Prednisone', 'بريدنيزون', 'TAB', 'Corticosteroid', '5mg', ['H02AB07']), + ('Azithromycin', 'أزيثروميسين', 'TAB', 'Antibiotic', '250mg', ['J01FA10']), + ('Ciprofloxacin', 'سيبروفلوكساسين', 'TAB', 'Antibiotic', '500mg', ['J01MA02']), + ('Gabapentin', 'جابابنتين', 'CAP', 'Anticonvulsant', '300mg', ['N03AX12']), + ('Tramadol', 'ترامادول', 'TAB', 'Opioid analgesic', '50mg', ['N02AX02']), +] + +SAUDI_ALLERGIES = [ + 'Penicillin', 'Aspirin', 'Ibuprofen', 'Sulfa drugs', 'Contrast dye', + 'Peanuts', 'Shellfish', 'Dairy', 'Eggs', 'Tree nuts', 'Latex' +] + +SAUDI_INSURANCE_COMPANIES = [ + 'Saudi Enaya Cooperative Insurance', 'Bupa Arabia', 'Tawuniya', + 'Malath Insurance', 'Walaa Insurance', 'Gulf Union Alahlia Insurance' +] + +SAUDI_CHIEF_COMPLAINTS = [ + 'Chest pain and shortness of breath', + 'Abdominal pain and nausea', + 'Headache and dizziness', + 'Back pain and stiffness', + 'Fever and cough', + 'Joint pain and swelling', + 'Fatigue and weakness', + 'Skin rash and itching', + 'Diabetes follow-up', + 'Hypertension monitoring', + 'Regular health checkup', + 'Vaccination appointment', + 'Pre-operative consultation', + 'Post-operative follow-up', + 'Pregnancy consultation', + 'Child wellness visit', + 'Mental health consultation', + 'Physical therapy session', + 'Diagnostic imaging', + 'Laboratory test follow-up', + 'Cardiac symptoms evaluation', + 'Respiratory symptoms', + 'Gastrointestinal complaints', + 'Neurological symptoms', + 'Endocrine disorders follow-up' +] + +SAUDI_LOCATIONS = [ + 'Main Building - Floor 1', + 'Main Building - Floor 2', + 'Main Building - Floor 3', + 'Emergency Wing', + 'Outpatient Clinic - Wing A', + 'Outpatient Clinic - Wing B', + 'Surgical Suite - Floor 4', + 'Radiology Department', + 'Laboratory Building', + 'Pediatric Wing', + 'ICU - Floor 5', + 'Cardiology Unit', + 'Maternity Ward', + 'Dialysis Center' +] + +# ================================ +# SAUDI TRAINING AND EDUCATION +# ================================ + +SAUDI_TRAINING_PROGRAMS = [ + 'Basic Life Support (BLS)', 'Advanced Cardiac Life Support (ACLS)', + 'Pediatric Advanced Life Support (PALS)', 'Infection Control', + 'Patient Safety', 'Fire Safety', 'Emergency Procedures', + 'HIPAA Compliance', 'Cultural Sensitivity', 'Arabic Language', + 'Islamic Healthcare Ethics', 'Medication Administration', + 'Wound Care Management', 'Electronic Health Records', + 'Quality Improvement', 'Customer Service Excellence' +] + +# ================================ +# SAUDI FACILITY MANAGEMENT +# ================================ + +SAUDI_MEDICAL_MANUFACTURERS = [ + 'Saudi Pharmaceutical Industries (SPIMACO)', + 'Tabuk Pharmaceuticals', + 'Al-Jazeera Pharmaceutical Industries', + 'Medical Supplies Company', + 'Saudi Medical Supplies', + 'Gulf Pharmaceutical Industries', + 'Middle East Healthcare', + 'Arabian Medical Equipment', + 'Riyadh Pharma', + 'Johnson & Johnson Saudi', + 'Pfizer Saudi Arabia', + 'Novartis Saudi', + 'Roche Saudi Arabia', + 'Abbott Saudi Arabia' +] + +SAUDI_STORAGE_LOCATIONS = { + 'buildings': ['Main Hospital', 'Outpatient Clinic', 'Emergency Wing', 'Research Center', 'Administrative Building'], + 'floors': ['Ground Floor', 'First Floor', 'Second Floor', 'Third Floor', 'Basement'], + 'rooms': ['Pharmacy', 'Central Supply', 'OR Storage', 'ICU Supply', 'Ward Storage', 'Emergency Supply'], + 'zones': ['Zone A', 'Zone B', 'Zone C', 'Zone D'], + 'aisles': ['Aisle 1', 'Aisle 2', 'Aisle 3', 'Aisle 4', 'Aisle 5'], + 'shelves': ['Shelf A', 'Shelf B', 'Shelf C', 'Shelf D', 'Shelf E'], + 'bins': ['Bin 1', 'Bin 2', 'Bin 3', 'Bin 4', 'Bin 5'] +} + +SAUDI_SUPPLIER_DATA = [ + { + 'name': 'Saudi Medical Supply Co.', + 'type': 'DISTRIBUTOR', + 'city': 'Riyadh', + 'phone': '+966-11-234-5678' + }, + { + 'name': 'Gulf Medical Equipment', + 'type': 'MANUFACTURER', + 'city': 'Dammam', + 'phone': '+966-13-345-6789' + }, + { + 'name': 'Arabian Healthcare Supplies', + 'type': 'WHOLESALER', + 'city': 'Jeddah', + 'phone': '+966-12-456-7890' + }, + { + 'name': 'Riyadh Medical Trading', + 'type': 'DISTRIBUTOR', + 'city': 'Riyadh', + 'phone': '+966-11-567-8901' + }, + { + 'name': 'Al-Dawaa Medical', + 'type': 'MANUFACTURER', + 'city': 'Medina', + 'phone': '+966-14-678-9012' + }, + { + 'name': 'Nahdi Medical Company', + 'type': 'RETAILER', + 'city': 'Jeddah', + 'phone': '+966-12-789-0123' + }, + { + 'name': 'United Pharmaceuticals', + 'type': 'MANUFACTURER', + 'city': 'Khobar', + 'phone': '+966-13-890-1234' + }, + { + 'name': 'Middle East Medical', + 'type': 'DISTRIBUTOR', + 'city': 'Taif', + 'phone': '+966-12-901-2345' + }, + { + 'name': 'Kingdom Medical Supplies', + 'type': 'WHOLESALER', + 'city': 'Buraidah', + 'phone': '+966-16-012-3456' + }, + { + 'name': 'Eastern Province Medical Co.', + 'type': 'DISTRIBUTOR', + 'city': 'Dhahran', + 'phone': '+966-13-123-4567' + } +] + +# ================================ +# ROLE DISTRIBUTION PATTERNS +# ================================ + +ROLE_DISTRIBUTION = { + 'PHYSICIAN': 0.15, + 'NURSE': 0.25, + 'PHARMACIST': 0.08, + 'LAB_TECH': 0.10, + 'RAD_TECH': 0.08, + 'RADIOLOGIST': 0.05, + 'ADMIN': 0.07, + 'MEDICAL_ASSISTANT': 0.12, + 'CLERICAL': 0.10 +} + +# ================================ +# LICENSE PREFIXES +# ================================ + +SAUDI_LICENSE_PREFIXES = ['MOH', 'SCFHS', 'SMLE', 'SFH'] + +# ================================ +# HOSPITAL NAMES +# ================================ + +SAUDI_HOSPITALS = [ + 'King Faisal Specialist Hospital', 'King Fahd Medical City', 'National Guard Health Affairs', + 'Prince Sultan Military Medical City', 'King Abdulaziz Medical City', 'King Saud Medical City', + 'Dr. Sulaiman Al Habib Medical Group', 'Saudi German Hospital', 'International Medical Center', + 'King Khalid University Hospital', 'King Abdulaziz University Hospital', + 'Prince Mohammed bin Abdulaziz Hospital', 'King Fahd Hospital', 'Imam Abdulrahman Bin Faisal Hospital' +] diff --git a/data_utils/generators.py b/data_utils/generators.py new file mode 100644 index 00000000..6e730a52 --- /dev/null +++ b/data_utils/generators.py @@ -0,0 +1,357 @@ +""" +Shared data generation functions for Saudi healthcare data. +Contains common generators used across multiple data generation modules. +""" +import os +import django + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'hospital_management.settings') +try: + django.setup() +except Exception as e: + print(f"Django setup failed: {e}") + print("Please run this script using: python manage.py shell -c \"exec(open('hr_data.py').read())\"") + exit(1) + +import random +import uuid +from datetime import datetime, timedelta, date +from decimal import Decimal +from django.utils import timezone as django_timezone +from django.contrib.auth import get_user_model + +from .constants import ( + SAUDI_FIRST_NAMES_MALE, SAUDI_FIRST_NAMES_FEMALE, SAUDI_FAMILY_NAMES, + SAUDI_CITIES, SAUDI_PROVINCES, SAUDI_LICENSE_PREFIXES, SAUDI_JOB_TITLES +) + +from accounts.models import User + + +# ================================ +# PHONE AND ID GENERATORS +# ================================ + +def generate_saudi_mobile_e164(): + """Generate Saudi E.164 mobile: +9665XXXXXXXX""" + return f"+9665{random.randint(10000000, 99999999)}" + + +def generate_saudi_phone(): + """Generate Saudi phone number""" + return f"+966{random.randint(11, 17)}{random.randint(1000000, 9999999)}" + + +def generate_saudi_license(): + """Generate Saudi medical license number (fictional format)""" + prefix = random.choice(SAUDI_LICENSE_PREFIXES) + return f"{prefix}-{random.randint(100000, 999999)}" + + +def generate_saudi_medical_record_number(): + """Generate medical record number""" + return f"MRN{random.randint(100000, 999999)}" + + +def generate_saudi_item_code(): + """Generate Saudi medical item code""" + return f"SAU-{random.randint(100000, 999999)}" + + +def generate_saudi_lot_number(): + """Generate Saudi lot number""" + return f"LOT{random.randint(2024, 2025)}{random.randint(100, 999)}" + + +def generate_saudi_po_number(): + """Generate Saudi purchase order number""" + return f"PO-{random.randint(2024, 2025)}-{random.randint(1000, 9999)}" + + +# ================================ +# NAME AND USERNAME GENERATORS +# ================================ + +def generate_saudi_name(gender=None): + """Generate a complete Saudi name""" + if gender is None: + gender = random.choice(['MALE', 'FEMALE']) + + if gender == 'MALE': + first_name = random.choice(SAUDI_FIRST_NAMES_MALE) + else: + first_name = random.choice(SAUDI_FIRST_NAMES_FEMALE) + + last_name = random.choice(SAUDI_FAMILY_NAMES) + + return { + 'first_name': first_name, + 'last_name': last_name, + 'full_name': f"{first_name} {last_name}" + } + + +def tenant_scoped_unique_username(tenant, base_username: str) -> str: + """ + Make username unique within a tenant (AUTH is tenant-scoped). + """ + uname = base_username + i = 1 + while User.objects.filter(tenant=tenant, username=uname).exists(): + i += 1 + uname = f"{base_username}{i}" + return uname + + +def generate_saudi_email(first_name, last_name, domain="email.com"): + """Generate Saudi-style email address""" + return f"{first_name.lower()}.{last_name.lower().replace('-', '').replace(' ', '')}@{domain}" + + +# ================================ +# ADDRESS GENERATORS +# ================================ + +def generate_saudi_address(): + """Generate a complete Saudi address""" + return { + 'address_line_1': f"{random.randint(1, 999)} {random.choice(['King Fahd Road', 'Prince Sultan Street', 'Al Malik Road'])}", + 'address_line_2': f"Apt {random.randint(1, 50)}" if random.choice([True, False]) else None, + 'city': random.choice(SAUDI_CITIES), + 'state': random.choice(SAUDI_PROVINCES), + 'postal_code': f"{random.randint(10000, 99999)}", + 'country': 'Saudi Arabia' + } + + +# ================================ +# MEDICAL DATA GENERATORS +# ================================ + +def pick_job_title_for_department(department) -> str: + """Pick appropriate job title based on department type""" + dtype = getattr(department, 'department_type', 'ADMINISTRATIVE') or 'ADMINISTRATIVE' + + if dtype == 'CLINICAL': + return random.choice([ + 'Consultant Physician', 'Senior Physician', 'Physician', 'Resident Physician', 'Intern', + 'Chief Nurse', 'Nurse Manager', 'Senior Nurse', 'Staff Nurse', + 'Nurse Practitioner', 'Clinical Nurse Specialist', 'Charge Nurse', + 'Pharmacist', 'Clinical Pharmacist', 'Pharmacy Technician', + 'Radiologist', 'Radiology Technician', 'Medical Technologist', + 'Laboratory Technician', 'Physical Therapist', 'Respiratory Therapist' + ]) + elif dtype == 'SUPPORT': + return random.choice([ + 'Security Officer', 'Security Guard', 'Maintenance Technician', + 'Housekeeping Supervisor', 'Housekeeper', 'Food Service Manager', + 'Cook', 'Kitchen Assistant', 'Transport Aide', 'Receptionist' + ]) + else: + return random.choice([ + 'Chief Executive Officer', 'Chief Operating Officer', 'Administrator', + 'Assistant Administrator', 'Department Manager', 'Supervisor', + 'Administrative Assistant', 'Secretary', 'Clerk', 'Coordinator' + ]) + + +def infer_role_from_title(job_title: str) -> str: + """Infer role from job title""" + jt = (job_title or '').lower() + if 'physician' in jt: + return 'PHYSICIAN' + if 'nurse practitioner' in jt: + return 'NURSE_PRACTITIONER' + if 'nurse' in jt: + return 'NURSE' + if 'pharmac' in jt: + return 'PHARMACIST' + if 'radiolog' in jt and 'techn' not in jt: + return 'RADIOLOGIST' + if 'radiolog' in jt and 'techn' in jt: + return 'RAD_TECH' + if 'laborator' in jt: + return 'LAB_TECH' + if any(k in jt for k in ['chief', 'director', 'manager', 'admin']): + return 'ADMIN' + return 'CLERICAL' + + +def generate_saudi_provider_email(first_name, last_name, tenant_domain): + """Generate provider email for tenant""" + return f"{first_name.lower()}.{last_name.lower().replace('-', '')}@{tenant_domain}" + + +# ================================ +# FINANCIAL GENERATORS +# ================================ + +def generate_saudi_tax_id(): + """Generate Saudi tax ID""" + return f"TAX-{random.randint(100000000, 999999999)}" + + +def generate_saudi_vat_number(): + """Generate Saudi VAT number (15 digits)""" + return f"{random.randint(10**14, 10**15 - 1)}" + + +def generate_saudi_crn(): + """Generate Saudi Commercial Registration Number""" + return f"{random.randint(10**9, 10**10 - 1)}" + + +# ================================ +# TIME AND DATE GENERATORS +# ================================ + +def generate_birth_date(min_age=1, max_age=85): + """Generate birth date within age range""" + birth_year = random.randint(1939, 2006) + birth_month = random.randint(1, 12) + birth_day = random.randint(1, 28) + return date(birth_year, birth_month, birth_day) + + +def generate_hire_date(max_years_ago=20): + """Generate hire date within reasonable range""" + start_date = django_timezone.now().date() - timedelta(days=365 * max_years_ago) + end_date = django_timezone.now().date() - timedelta(days=30) + days_range = (end_date - start_date).days + random_days = random.randint(0, days_range) + return start_date + timedelta(days=random_days) + + +def generate_future_date(days_ahead=365): + """Generate future date""" + return django_timezone.now().date() + timedelta(days=random.randint(1, days_ahead)) + + +def generate_past_date(days_back=365): + """Generate past date""" + return django_timezone.now().date() - timedelta(days=random.randint(1, days_back)) + + +# ================================ +# CLINICAL DATA GENERATORS +# ================================ + +def generate_vital_signs(patient_age=None): + """Generate realistic vital signs based on age""" + if patient_age is None: + patient_age = random.randint(1, 85) + + # Temperature (Celsius) + temperature = round(random.uniform(36.0, 39.5), 1) + + # Blood pressure + if patient_age < 18: + systolic_bp = random.randint(90, 120) + diastolic_bp = random.randint(50, 80) + elif patient_age < 65: + systolic_bp = random.randint(100, 140) + diastolic_bp = random.randint(60, 90) + else: + systolic_bp = random.randint(110, 160) + diastolic_bp = random.randint(70, 100) + + # Heart rate + if patient_age < 1: + heart_rate = random.randint(100, 160) + elif patient_age < 12: + heart_rate = random.randint(80, 120) + else: + heart_rate = random.randint(60, 100) + + # Respiratory rate + if patient_age < 1: + respiratory_rate = random.randint(30, 60) + elif patient_age < 12: + respiratory_rate = random.randint(18, 30) + else: + respiratory_rate = random.randint(12, 20) + + # Oxygen saturation + oxygen_saturation = random.randint(95, 100) + + # Weight and height + if patient_age < 18: + weight = round(random.uniform(3.0, 70.0), 1) + height = round(random.uniform(50.0, 180.0), 1) + else: + weight = round(random.uniform(45.0, 150.0), 1) + height = round(random.uniform(150.0, 200.0), 1) + + return { + 'temperature': temperature, + 'systolic_bp': systolic_bp, + 'diastolic_bp': diastolic_bp, + 'heart_rate': heart_rate, + 'respiratory_rate': respiratory_rate, + 'oxygen_saturation': oxygen_saturation, + 'weight': weight, + 'height': height + } + + +def generate_lab_values(test_type='QUANTITATIVE', reference_range=None): + """Generate realistic lab values""" + if test_type == 'QUANTITATIVE' and reference_range: + # Generate value within or outside reference range + if random.random() < 0.8: # 80% normal + low = float(reference_range.get('range_low', 0)) + high = float(reference_range.get('range_high', 100)) + value = round(random.uniform(low, high), 2) + abnormal_flag = 'N' + else: # 20% abnormal + low = float(reference_range.get('range_low', 0)) + high = float(reference_range.get('range_high', 100)) + if random.choice([True, False]): + value = round(random.uniform(high * 1.1, high * 2.0), 2) + abnormal_flag = 'H' + else: + value = round(random.uniform(low * 0.1, low * 0.9), 2) + abnormal_flag = 'L' + else: + # Qualitative result + qualitative_results = ['Negative', 'Positive', 'Normal', 'Abnormal', 'Detected', 'Not Detected'] + value = random.choice(qualitative_results) + abnormal_flag = 'A' if value in ['Positive', 'Abnormal', 'Detected'] else 'N' + + return value, abnormal_flag + + +# ================================ +# UTILITY FUNCTIONS +# ================================ + +def safe_choice(seq): + """Safe choice that handles empty sequences""" + return random.choice(seq) if seq else None + + +def random_percentage(): + """Generate random percentage 0-100""" + return random.randint(0, 100) + + +def random_decimal(min_val, max_val, precision="0.01"): + """Generate random decimal within range""" + q = Decimal(precision) + return Decimal(str(random.uniform(float(min_val), float(max_val)))).quantize(q) + + +def generate_uuid(): + """Generate UUID string""" + return str(uuid.uuid4()) + + +def generate_unique_code(prefix, existing_codes=None): + """Generate unique code with prefix""" + if existing_codes is None: + existing_codes = set() + + while True: + code = f"{prefix}{random.randint(100000, 999999)}" + if code not in existing_codes: + return code diff --git a/data_utils/helpers.py b/data_utils/helpers.py new file mode 100644 index 00000000..e2a6afe5 --- /dev/null +++ b/data_utils/helpers.py @@ -0,0 +1,371 @@ +""" +Database utilities and model helpers for data generation. +Contains common database operations and model field utilities. +""" + +import random +from datetime import datetime, timedelta +from decimal import Decimal +from django.db import transaction, IntegrityError +from django.db.models import DecimalField, CharField, IntegerField +from django.db.models.fields import NOT_PROVIDED +from django.utils import timezone as django_timezone + + +# ================================ +# MODEL FIELD UTILITIES +# ================================ + +def _model_fields(Model): + """Get all concrete model fields""" + return {f.name for f in Model._meta.get_fields() if getattr(f, "concrete", False) and not f.auto_created} + + +def _filter_kwargs(Model, data: dict): + """Filter dictionary to only include valid model fields""" + allowed = _model_fields(Model) + return {k: v for k, v in data.items() if k in allowed} + + +def _quantize_for_field(value: Decimal, f: DecimalField) -> Decimal: + """Quantize a Decimal to a field's decimal_places and clamp to max_digits.""" + # Quantize to required decimal_places + q = Decimal(1).scaleb(-f.decimal_places) # 10^(-decimal_places) + v = Decimal(value).quantize(q) + + # Ensure total digits <= max_digits (digits before + decimal_places) + # Count digits before decimal: + sign, digits, exp = v.as_tuple() + digits_str_len = len(digits) + # number of digits after decimal is decimal_places + digits_before = digits_str_len - f.decimal_places if f.decimal_places else digits_str_len + # if v is 0.x and decimal_places > digits, digits_before can be negative; normalize + if digits_before < 0: + digits_before = 0 + + max_before = f.max_digits - f.decimal_places + if max_before < 0: + max_before = 0 + + # If too many digits before decimal, clamp to the largest representable value + if digits_before > max_before: + # Largest integer part we can store is 10^max_before - 1 + max_int = (10 ** max_before) - 1 if max_before > 0 else 0 + v = Decimal(max_int).quantize(q) + return v + + +def _default_decimal_for(BillingConfiguration, f: DecimalField) -> Decimal: + """Return a safe default decimal per field name & precision.""" + name = f.name.lower() + if any(k in name for k in ["tax", "vat", "rate"]): + # Prefer 15% if it's a rate; use 15 if integer percent + base = Decimal("15") if f.decimal_places == 0 else Decimal("0.15") + else: + base = Decimal("0") + return _quantize_for_field(base, f) + + +# ================================ +# SEQUENTIAL NUMBER GENERATORS +# ================================ + +def _next_seq_number(prefix, Model, field): + """Generate next sequential number for a model field""" + today = django_timezone.now().date().strftime("%Y%m%d") + i = 1 + while True: + candidate = f"{prefix}-{today}-{i:04d}" + if not Model.objects.filter(**{field: candidate}).exists(): + return candidate + i += 1 + + +def _next_claim_number(): + """Generate next insurance claim number""" + from billing.models import InsuranceClaim + return _next_seq_number("CLM", InsuranceClaim, "claim_number") + + +def _next_payment_number(): + """Generate next payment number""" + from billing.models import Payment + return _next_seq_number("PMT", Payment, "payment_number") + + +def _next_bill_number(): + """Generate next bill number""" + from billing.models import MedicalBill + return _next_seq_number("BILL", MedicalBill, "bill_number") + + +def _next_case_number(tenant): + """Generate next surgical case number""" + from operating_theatre.models import SurgicalCase + return f"CASE-{tenant.slug.upper()}-{django_timezone.now().year}-{random.randint(1000, 9999):05d}" + + +# ================================ +# DATABASE OPERATIONS +# ================================ + +def safe_bulk_create(Model, objects, batch_size=1000, ignore_conflicts=False): + """Safely bulk create objects with error handling""" + if not objects: + return 0 + + created_count = 0 + for i in range(0, len(objects), batch_size): + batch = objects[i:i + batch_size] + try: + with transaction.atomic(): + if ignore_conflicts: + Model.objects.bulk_create(batch, ignore_conflicts=True) + else: + Model.objects.bulk_create(batch) + created_count += len(batch) + except IntegrityError as e: + print(f"Integrity error in batch {i//batch_size + 1}: {e}") + # Try individual creates for problematic batch + for obj in batch: + try: + obj.save() + created_count += 1 + except IntegrityError: + continue # Skip duplicates + except Exception as e: + print(f"Error in batch {i//batch_size + 1}: {e}") + continue + + return created_count + + +def safe_get_or_create(Model, defaults=None, **kwargs): + """Safe get_or_create with error handling""" + try: + return Model.objects.get_or_create(defaults=defaults or {}, **kwargs) + except Exception as e: + print(f"Error in get_or_create for {Model.__name__}: {e}") + return None, False + + +def safe_create(Model, **kwargs): + """Safe create with error handling""" + try: + return Model.objects.create(**kwargs) + except Exception as e: + print(f"Error creating {Model.__name__}: {e}") + return None + + +# ================================ +# TENANT UTILITIES +# ================================ + +def get_tenant_users(tenant, roles=None, limit=20): + """Get users for a tenant with optional role filtering""" + from django.contrib.auth import get_user_model + User = get_user_model() + + qs = User.objects.filter(tenant=tenant, is_active=True) + if roles: + qs = qs.filter(employee_profile__role__in=roles) + return list(qs[:limit]) + + +def get_tenant_providers(tenant, clinical_only=True): + """Get healthcare providers for a tenant""" + from django.contrib.auth import get_user_model + User = get_user_model() + + clinical_roles = ['PHYSICIAN', 'NURSE_PRACTITIONER', 'PHYSICIAN_ASSISTANT', 'RADIOLOGIST'] + qs = User.objects.filter(tenant=tenant, is_active=True) + + if clinical_only: + qs = qs.filter(employee_profile__role__in=clinical_roles) + + return list(qs) + + +def get_tenant_patients(tenant, limit=100): + """Get patients for a tenant""" + from patients.models import PatientProfile + return list(PatientProfile.objects.filter(tenant=tenant)[:limit]) + + +def get_tenant_departments(tenant): + """Get departments for a tenant""" + from hr.models import Department + return list(Department.objects.filter(tenant=tenant)) + + +# ================================ +# PROGRESS TRACKING +# ================================ + +class ProgressTracker: + """Track progress of data generation operations""" + + def __init__(self, total_operations=0): + self.total_operations = total_operations + self.completed_operations = 0 + self.start_time = django_timezone.now() + + def increment(self, count=1): + """Increment completed operations""" + self.completed_operations += count + + def get_progress(self): + """Get current progress percentage""" + if self.total_operations == 0: + return 100 + return int((self.completed_operations / self.total_operations) * 100) + + def get_eta(self): + """Estimate time remaining""" + if self.completed_operations == 0: + return None + + elapsed = django_timezone.now() - self.start_time + total_estimated = elapsed * (self.total_operations / self.completed_operations) + remaining = total_estimated - elapsed + + return remaining + + def print_progress(self, operation_name=""): + """Print current progress""" + progress = self.get_progress() + eta = self.get_eta() + eta_str = f" ETA: {eta}" if eta else "" + + print(f"[{progress:3d}%] {operation_name}{eta_str}") + + +# ================================ +# VALIDATION UTILITIES +# ================================ + +def validate_tenant_exists(tenant_id=None, tenant_slug=None): + """Validate that tenant exists""" + from core.models import Tenant + + if tenant_id: + try: + return Tenant.objects.get(id=tenant_id) + except Tenant.DoesNotExist: + raise ValueError(f"Tenant with ID {tenant_id} does not exist") + + if tenant_slug: + try: + return Tenant.objects.get(slug=tenant_slug) + except Tenant.DoesNotExist: + raise ValueError(f"Tenant with slug {tenant_slug} does not exist") + + # Return first active tenant if no specific tenant requested + tenant = Tenant.objects.filter(is_active=True).first() + if not tenant: + raise ValueError("No active tenants found") + + return tenant + + +def validate_dependencies(): + """Validate that all required dependencies exist""" + from core.models import Tenant + from django.contrib.auth import get_user_model + + User = get_user_model() + + # Check for tenants + tenant_count = Tenant.objects.filter(is_active=True).count() + if tenant_count == 0: + raise ValueError("No active tenants found. Please create tenants first.") + + # Check for users + user_count = User.objects.filter(is_active=True).count() + if user_count == 0: + raise ValueError("No active users found. Please create users first.") + + return { + 'tenants': tenant_count, + 'users': user_count + } + + +# ================================ +# CLEANUP UTILITIES +# ================================ + +def cleanup_test_data(tenant=None, confirm=False): + """Clean up test data (use with caution!)""" + if not confirm: + print("WARNING: This will delete test data. Set confirm=True to proceed.") + return + + from django.core.management import call_command + + # Reset sequences and clear data + models_to_clear = [ + 'laboratory.LabResult', + 'laboratory.Specimen', + 'laboratory.LabOrder', + 'emr.ClinicalNote', + 'emr.CarePlan', + 'emr.ProblemList', + 'emr.VitalSigns', + 'emr.Encounter', + 'appointments.AppointmentRequest', + 'patients.PatientProfile', + 'hr.Employee', + 'accounts.User', + ] + + for model in models_to_clear: + try: + call_command('flush', model, verbosity=0, interactive=False) + except Exception as e: + print(f"Error clearing {model}: {e}") + + print("Test data cleanup completed.") + + +# ================================ +# BATCH PROCESSING UTILITIES +# ================================ + +def batch_process(items, batch_size=100, process_func=None, progress_callback=None): + """Process items in batches with progress tracking""" + if not process_func: + return + + total_batches = (len(items) + batch_size - 1) // batch_size + processed = 0 + + for i in range(0, len(items), batch_size): + batch = items[i:i + batch_size] + batch_num = (i // batch_size) + 1 + + try: + process_func(batch) + processed += len(batch) + + if progress_callback: + progress_callback(batch_num, total_batches, processed, len(items)) + + except Exception as e: + print(f"Error processing batch {batch_num}: {e}") + continue + + return processed + + +def create_with_retry(Model, max_retries=3, **kwargs): + """Create model instance with retry on integrity errors""" + for attempt in range(max_retries): + try: + return Model.objects.create(**kwargs) + except IntegrityError: + if attempt == max_retries - 1: + raise + continue + return None diff --git a/db.sqlite3 b/db.sqlite3 index 7b9ac4d5..e9d90dfb 100644 Binary files a/db.sqlite3 and b/db.sqlite3 differ diff --git a/emr/__pycache__/forms.cpython-312.pyc b/emr/__pycache__/forms.cpython-312.pyc index 7bfeaf70..f00c777f 100644 Binary files a/emr/__pycache__/forms.cpython-312.pyc and b/emr/__pycache__/forms.cpython-312.pyc differ diff --git a/emr/__pycache__/models.cpython-312.pyc b/emr/__pycache__/models.cpython-312.pyc index 06b094d4..f1b94154 100644 Binary files a/emr/__pycache__/models.cpython-312.pyc and b/emr/__pycache__/models.cpython-312.pyc differ diff --git a/emr/__pycache__/urls.cpython-312.pyc b/emr/__pycache__/urls.cpython-312.pyc index abc5af54..c3ba37db 100644 Binary files a/emr/__pycache__/urls.cpython-312.pyc and b/emr/__pycache__/urls.cpython-312.pyc differ diff --git a/emr/__pycache__/views.cpython-312.pyc b/emr/__pycache__/views.cpython-312.pyc index 9596df6a..238bc9d0 100644 Binary files a/emr/__pycache__/views.cpython-312.pyc and b/emr/__pycache__/views.cpython-312.pyc differ diff --git a/emr/forms.py b/emr/forms.py index 7feb2de5..83ca7690 100644 --- a/emr/forms.py +++ b/emr/forms.py @@ -130,6 +130,18 @@ class ProblemListForm(forms.ModelForm): """ Form for creating and updating problems. """ + # Custom field for ICD-10 search + icd10_search = forms.CharField( + required=False, + widget=forms.TextInput(attrs={ + 'class': 'form-control form-control-sm', + 'placeholder': 'Search ICD-10 diagnoses...', + 'id': 'icd10-search', + 'autocomplete': 'off' + }), + help_text='Start typing to search ICD-10 diagnoses' + ) + class Meta: model = ProblemList fields = [ @@ -144,13 +156,37 @@ class ProblemListForm(forms.ModelForm): 'verified', 'verified_by' ] widgets = { - 'onset_date': forms.DateInput(attrs={'type': 'date'}), - 'resolution_date': forms.DateInput(attrs={'type': 'date'}), - 'clinical_notes': forms.Textarea(attrs={'rows': 3}), - 'patient_concerns': forms.Textarea(attrs={'rows': 3}), - 'resolution_notes': forms.Textarea(attrs={'rows': 3}), - 'treatment_goals': forms.Textarea(attrs={'rows': 3}), - 'outcome_measures': forms.Textarea(attrs={'rows': 3}), + 'patient': forms.Select(attrs={'class': 'form-select form-select-sm'}), + 'related_encounter': forms.Select(attrs={'class': 'form-select form-select-sm'}), + 'problem_name': forms.TextInput(attrs={ + 'class': 'form-control form-control-sm', + 'placeholder': 'Problem name (auto-filled from ICD-10)', + 'readonly': True + }), + 'problem_code': forms.TextInput(attrs={ + 'class': 'form-control form-control-sm', + 'placeholder': 'ICD-10 code (auto-filled)', + 'readonly': True + }), + 'coding_system': forms.Select(attrs={'class': 'form-select form-select-sm'}), + 'problem_type': forms.Select(attrs={'class': 'form-select form-select-sm'}), + 'onset_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control form-control-sm'}), + 'onset_description': forms.TextInput(attrs={'class': 'form-control form-control-sm', 'placeholder': 'Describe onset'}), + 'severity': forms.Select(attrs={'class': 'form-select form-select-sm'}), + 'priority': forms.Select(attrs={'class': 'form-select form-select-sm'}), + 'status': forms.Select(attrs={'class': 'form-select form-select-sm'}), + 'resolution_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control form-control-sm'}), + 'body_site': forms.TextInput(attrs={'class': 'form-control form-control-sm', 'placeholder': 'Body site affected'}), + 'laterality': forms.Select(attrs={'class': 'form-select form-select-sm'}), + 'diagnosing_provider': forms.Select(attrs={'class': 'form-select form-select-sm'}), + 'managing_provider': forms.Select(attrs={'class': 'form-select form-select-sm'}), + 'verified_by': forms.Select(attrs={'class': 'form-select form-select-sm'}), + 'clinical_notes': forms.Textarea(attrs={'rows': 3, 'class': 'form-control form-control-sm', 'placeholder': 'Clinical notes'}), + 'patient_concerns': forms.Textarea(attrs={'rows': 3, 'class': 'form-control form-control-sm', 'placeholder': 'Patient concerns'}), + 'resolution_notes': forms.Textarea(attrs={'rows': 3, 'class': 'form-control form-control-sm', 'placeholder': 'Resolution notes'}), + 'treatment_goals': forms.Textarea(attrs={'rows': 3, 'class': 'form-control form-control-sm', 'placeholder': 'Treatment goals'}), + 'outcome_measures': forms.Textarea(attrs={'rows': 3, 'class': 'form-control form-control-sm', 'placeholder': 'Outcome measures'}), + 'verified': forms.CheckboxInput(attrs={'class': 'form-check-input'}), } def __init__(self, *args, **kwargs): @@ -425,15 +461,23 @@ class NoteTemplateForm(forms.ModelForm): 'previous_version', 'quality_indicators', 'compliance_requirements' ] widgets = { - 'description': forms.Textarea(attrs={'rows': 3}), - 'template_content': forms.Textarea(attrs={'rows': 10, 'class': 'template-editor'}), - 'structured_fields': forms.Textarea(attrs={'rows': 5}), - 'quality_indicators': forms.Textarea(attrs={'rows': 3}), - 'compliance_requirements': forms.Textarea(attrs={'rows': 3}), + 'name': forms.TextInput(attrs={'class': 'form-control form-control-sm'}), + 'description': forms.Textarea(attrs={'rows': 3, 'class': 'form-control form-control-sm'}), + 'note_type': forms.Select(attrs={'class': 'form-control form-control-sm'}), + 'template_content': forms.Textarea(attrs={'rows': 10, 'class': 'form-control form-control-sm'}), + 'structured_fields': forms.Textarea(attrs={'rows': 5, 'class': 'form-control form-control-sm'}), + 'quality_indicators': forms.Textarea(attrs={'rows': 3, 'class': 'form-control form-control-sm'}), + 'compliance_requirements': forms.Textarea(attrs={'rows': 3, 'class': 'form-control form-control-sm'}), + 'version': forms.TextInput(attrs={'class': 'form-control form-control-sm'}), + 'previous_version': forms.Select(attrs={'class': 'form-control form-control-sm'}), + 'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + 'is_default': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + 'specialty': forms.Select(attrs={'class': 'form-control form-control-sm'}), } def __init__(self, *args, **kwargs): self.tenant = kwargs.pop('tenant', None) + self.user = kwargs.pop('user', None) super().__init__(*args, **kwargs) # Filter related objects by tenant @@ -461,4 +505,3 @@ class NoteTemplateForm(forms.ModelForm): self.add_error('version', 'Version must be in format X.Y (e.g., 1.0)') return cleaned_data - diff --git a/emr/migrations/0001_initial.py b/emr/migrations/0001_initial.py index 7aad1d7a..31da0879 100644 --- a/emr/migrations/0001_initial.py +++ b/emr/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-09-19 10:58 +# Generated by Django 5.2.6 on 2025-09-26 18:33 import django.core.validators import django.db.models.deletion @@ -607,6 +607,78 @@ class Migration(migrations.Migration): "ordering": ["-created_at"], }, ), + migrations.CreateModel( + name="TreatmentProtocol", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "protocol_id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + help_text="Unique protocol identifier", + unique=True, + ), + ), + ("name", models.CharField(help_text="Protocol name", max_length=200)), + ("description", models.TextField(help_text="Protocol description")), + ( + "indication", + models.TextField(help_text="Clinical indications for use"), + ), + ("goals", models.JSONField(default=list, help_text="Treatment goals")), + ( + "interventions", + models.JSONField(default=list, help_text="Required interventions"), + ), + ( + "monitoring_parameters", + models.JSONField(default=list, help_text="Parameters to monitor"), + ), + ( + "success_rate", + models.DecimalField( + decimal_places=2, + help_text="Protocol success rate (%)", + max_digits=5, + ), + ), + ( + "average_duration", + models.PositiveIntegerField( + help_text="Average treatment duration in days" + ), + ), + ( + "is_active", + models.BooleanField( + default=True, help_text="Protocol is active and available" + ), + ), + ( + "usage_count", + models.PositiveIntegerField( + default=0, help_text="Number of times protocol has been used" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "verbose_name": "Treatment Protocol", + "verbose_name_plural": "Treatment Protocols", + "db_table": "emr_treatment_protocol", + "ordering": ["-success_rate", "name"], + }, + ), migrations.CreateModel( name="VitalSigns", fields=[ @@ -911,6 +983,123 @@ class Migration(migrations.Migration): "ordering": ["-measured_datetime"], }, ), + migrations.CreateModel( + name="AllergyAlert", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "alert_id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + help_text="Unique alert identifier", + unique=True, + ), + ), + ( + "allergen", + models.CharField(help_text="Allergen name", max_length=100), + ), + ( + "reaction_type", + models.CharField( + help_text="Type of allergic reaction", max_length=100 + ), + ), + ( + "severity", + models.CharField( + choices=[ + ("MILD", "Mild"), + ("MODERATE", "Moderate"), + ("SEVERE", "Severe"), + ("LIFE_THREATENING", "Life-threatening"), + ], + help_text="Alert severity", + max_length=20, + ), + ), + ( + "symptoms", + models.TextField( + blank=True, help_text="Allergic reaction symptoms", null=True + ), + ), + ( + "onset", + models.CharField( + blank=True, + help_text="Reaction onset timing", + max_length=50, + null=True, + ), + ), + ( + "resolved", + models.BooleanField( + default=False, help_text="Alert has been resolved" + ), + ), + ( + "resolved_at", + models.DateTimeField( + blank=True, help_text="Date alert was resolved", null=True + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "detected_at", + models.DateTimeField( + default=django.utils.timezone.now, + help_text="When alert was detected", + ), + ), + ( + "patient", + models.ForeignKey( + help_text="Patient", + on_delete=django.db.models.deletion.CASCADE, + related_name="allergy_alerts", + to="patients.patientprofile", + ), + ), + ( + "resolved_by", + models.ForeignKey( + blank=True, + help_text="Provider who resolved the alert", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="resolved_allergy_alerts", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "tenant", + models.ForeignKey( + help_text="Organization tenant", + on_delete=django.db.models.deletion.CASCADE, + related_name="allergy_alerts", + to="core.tenant", + ), + ), + ], + options={ + "verbose_name": "Allergy Alert", + "verbose_name_plural": "Allergy Alerts", + "db_table": "emr_allergy_alert", + "ordering": ["-detected_at"], + }, + ), migrations.CreateModel( name="CarePlan", fields=[ @@ -1000,7 +1189,7 @@ class Migration(migrations.Migration): ("ON_HOLD", "On Hold"), ("COMPLETED", "Completed"), ("CANCELLED", "Cancelled"), - ("ENTERED_IN_ERROR", "Entered in Error"), + ("ERROR", "Entered in Error"), ("UNKNOWN", "Unknown"), ], default="DRAFT", @@ -1193,6 +1382,96 @@ class Migration(migrations.Migration): "ordering": ["-created_at"], }, ), + migrations.CreateModel( + name="ClinicalGuideline", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "guideline_id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + help_text="Unique guideline identifier", + unique=True, + ), + ), + ( + "title", + models.CharField(help_text="Guideline title", max_length=300), + ), + ( + "organization", + models.CharField( + help_text="Publishing organization", max_length=100 + ), + ), + ("summary", models.TextField(help_text="Guideline summary")), + ( + "url", + models.URLField( + blank=True, help_text="Link to full guideline", null=True + ), + ), + ( + "publication_date", + models.DateField(help_text="Guideline publication date"), + ), + ( + "last_updated", + models.DateField(auto_now=True, help_text="Last updated date"), + ), + ( + "version", + models.CharField( + blank=True, + help_text="Guideline version", + max_length=20, + null=True, + ), + ), + ( + "is_active", + models.BooleanField( + default=True, help_text="Guideline is current and active" + ), + ), + ( + "keywords", + models.JSONField(default=list, help_text="Keywords for searching"), + ), + ( + "specialties", + models.JSONField( + default=list, help_text="Relevant medical specialties" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "tenant", + models.ForeignKey( + help_text="Organization tenant", + on_delete=django.db.models.deletion.CASCADE, + related_name="clinical_guidelines", + to="core.tenant", + ), + ), + ], + options={ + "verbose_name": "Clinical Guideline", + "verbose_name_plural": "Clinical Guidelines", + "db_table": "emr_clinical_guideline", + "ordering": ["-last_updated", "title"], + }, + ), migrations.CreateModel( name="ClinicalNote", fields=[ @@ -1410,4 +1689,440 @@ class Migration(migrations.Migration): "ordering": ["-note_datetime"], }, ), + migrations.CreateModel( + name="ClinicalRecommendation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "recommendation_id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + help_text="Unique recommendation identifier", + unique=True, + ), + ), + ( + "title", + models.CharField(help_text="Recommendation title", max_length=200), + ), + ( + "description", + models.TextField(help_text="Detailed recommendation description"), + ), + ( + "category", + models.CharField( + choices=[ + ("PREVENTIVE", "Preventive Care"), + ("DIAGNOSTIC", "Diagnostic"), + ("TREATMENT", "Treatment"), + ("MONITORING", "Monitoring"), + ("LIFESTYLE", "Lifestyle"), + ("MEDICATION", "Medication"), + ("FOLLOW_UP", "Follow-up"), + ("REFERRAL", "Referral"), + ("EDUCATION", "Patient Education"), + ("OTHER", "Other"), + ], + help_text="Recommendation category", + max_length=20, + ), + ), + ( + "priority", + models.CharField( + choices=[ + ("LOW", "Low"), + ("MEDIUM", "Medium"), + ("HIGH", "High"), + ("URGENT", "Urgent"), + ("CRITICAL", "Critical"), + ], + default="MEDIUM", + help_text="Recommendation priority", + max_length=20, + ), + ), + ( + "evidence_level", + models.CharField( + help_text="Level of evidence (1A, 1B, 2A, etc.)", max_length=10 + ), + ), + ( + "source", + models.CharField( + help_text="Source of recommendation (guideline, study, etc.)", + max_length=100, + ), + ), + ( + "rationale", + models.TextField( + blank=True, + help_text="Clinical rationale for recommendation", + null=True, + ), + ), + ( + "status", + models.CharField( + choices=[ + ("PENDING", "Pending"), + ("ACTIVE", "Active"), + ("ACCEPTED", "Accepted"), + ("DEFERRED", "Deferred"), + ("DISMISSED", "Dismissed"), + ("COMPLETED", "Completed"), + ("EXPIRED", "Expired"), + ], + default="PENDING", + help_text="Current recommendation status", + max_length=20, + ), + ), + ( + "accepted_at", + models.DateTimeField( + blank=True, + help_text="Date and time recommendation was accepted", + null=True, + ), + ), + ( + "deferred_at", + models.DateTimeField( + blank=True, + help_text="Date and time recommendation was deferred", + null=True, + ), + ), + ( + "dismissed_at", + models.DateTimeField( + blank=True, + help_text="Date and time recommendation was dismissed", + null=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "expires_at", + models.DateTimeField( + blank=True, + help_text="Recommendation expiration date", + null=True, + ), + ), + ( + "accepted_by", + models.ForeignKey( + blank=True, + help_text="Provider who accepted the recommendation", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="accepted_recommendations", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + help_text="User who created the recommendation", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="created_recommendations", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deferred_by", + models.ForeignKey( + blank=True, + help_text="Provider who deferred the recommendation", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="deferred_recommendations", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "dismissed_by", + models.ForeignKey( + blank=True, + help_text="Provider who dismissed the recommendation", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="dismissed_recommendations", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "patient", + models.ForeignKey( + help_text="Patient", + on_delete=django.db.models.deletion.CASCADE, + related_name="clinical_recommendations", + to="patients.patientprofile", + ), + ), + ( + "tenant", + models.ForeignKey( + help_text="Organization tenant", + on_delete=django.db.models.deletion.CASCADE, + related_name="clinical_recommendations", + to="core.tenant", + ), + ), + ], + options={ + "verbose_name": "Clinical Recommendation", + "verbose_name_plural": "Clinical Recommendations", + "db_table": "emr_clinical_recommendation", + "ordering": ["-created_at"], + }, + ), + migrations.CreateModel( + name="CriticalAlert", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "alert_id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + help_text="Unique alert identifier", + unique=True, + ), + ), + ("title", models.CharField(help_text="Alert title", max_length=200)), + ("description", models.TextField(help_text="Alert description")), + ( + "priority", + models.CharField( + choices=[ + ("HIGH", "High"), + ("URGENT", "Urgent"), + ("CRITICAL", "Critical"), + ], + default="HIGH", + help_text="Alert priority level", + max_length=20, + ), + ), + ( + "recommendation", + models.TextField( + blank=True, help_text="Recommended actions", null=True + ), + ), + ( + "acknowledged", + models.BooleanField( + default=False, help_text="Alert has been acknowledged" + ), + ), + ( + "acknowledged_at", + models.DateTimeField( + blank=True, + help_text="Date and time alert was acknowledged", + null=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "expires_at", + models.DateTimeField( + blank=True, help_text="Alert expiration date", null=True + ), + ), + ( + "acknowledged_by", + models.ForeignKey( + blank=True, + help_text="Provider who acknowledged the alert", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="emr_acknowledged_alerts", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + help_text="User who created the alert", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="created_alerts", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "patient", + models.ForeignKey( + help_text="Patient", + on_delete=django.db.models.deletion.CASCADE, + related_name="critical_alerts", + to="patients.patientprofile", + ), + ), + ( + "tenant", + models.ForeignKey( + help_text="Organization tenant", + on_delete=django.db.models.deletion.CASCADE, + related_name="critical_alerts", + to="core.tenant", + ), + ), + ], + options={ + "verbose_name": "Critical Alert", + "verbose_name_plural": "Critical Alerts", + "db_table": "emr_critical_alert", + "ordering": ["-created_at"], + }, + ), + migrations.CreateModel( + name="DiagnosticSuggestion", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "suggestion_id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + help_text="Unique suggestion identifier", + unique=True, + ), + ), + ( + "test_name", + models.CharField(help_text="Suggested test name", max_length=200), + ), + ( + "test_code", + models.CharField( + help_text="Test code or identifier", max_length=20 + ), + ), + ( + "indication", + models.TextField(help_text="Clinical indication for the test"), + ), + ( + "confidence", + models.DecimalField( + decimal_places=2, + help_text="AI confidence score (%)", + max_digits=5, + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(100), + ], + ), + ), + ( + "status", + models.CharField( + choices=[ + ("PENDING", "Pending"), + ("ORDERED", "Ordered"), + ("COMPLETED", "Completed"), + ("CANCELLED", "Cancelled"), + ], + default="ORDERED", + help_text="Suggestion status", + max_length=20, + ), + ), + ( + "ordered_at", + models.DateTimeField( + blank=True, + help_text="Date and time test was ordered", + null=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + help_text="User who created the suggestion", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="created_suggestions", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "ordered_by", + models.ForeignKey( + blank=True, + help_text="Provider who ordered the test", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="ordered_suggestions", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "patient", + models.ForeignKey( + help_text="Patient", + on_delete=django.db.models.deletion.CASCADE, + related_name="diagnostic_suggestions", + to="patients.patientprofile", + ), + ), + ( + "tenant", + models.ForeignKey( + help_text="Organization tenant", + on_delete=django.db.models.deletion.CASCADE, + related_name="diagnostic_suggestions", + to="core.tenant", + ), + ), + ], + options={ + "verbose_name": "Diagnostic Suggestion", + "verbose_name_plural": "Diagnostic Suggestions", + "db_table": "emr_diagnostic_suggestion", + "ordering": ["-confidence", "-created_at"], + }, + ), ] diff --git a/emr/migrations/0002_initial.py b/emr/migrations/0002_initial.py index d0e6c866..c0076aed 100644 --- a/emr/migrations/0002_initial.py +++ b/emr/migrations/0002_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-09-19 10:58 +# Generated by Django 5.2.6 on 2025-09-26 18:33 import django.db.models.deletion from django.conf import settings @@ -97,6 +97,30 @@ class Migration(migrations.Migration): to="core.tenant", ), ), + migrations.AddField( + model_name="criticalalert", + name="related_encounter", + field=models.ForeignKey( + blank=True, + help_text="Related encounter", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="critical_alerts", + to="emr.encounter", + ), + ), + migrations.AddField( + model_name="clinicalrecommendation", + name="related_encounter", + field=models.ForeignKey( + blank=True, + help_text="Related encounter", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="recommendations", + to="emr.encounter", + ), + ), migrations.AddField( model_name="clinicalnote", name="encounter", @@ -242,6 +266,16 @@ class Migration(migrations.Migration): to=settings.AUTH_USER_MODEL, ), ), + migrations.AddField( + model_name="clinicalrecommendation", + name="related_problems", + field=models.ManyToManyField( + blank=True, + help_text="Related problems", + related_name="recommendations", + to="emr.problemlist", + ), + ), migrations.AddField( model_name="clinicalnote", name="related_problems", @@ -262,6 +296,28 @@ class Migration(migrations.Migration): to="emr.problemlist", ), ), + migrations.AddField( + model_name="treatmentprotocol", + name="created_by", + field=models.ForeignKey( + blank=True, + help_text="User who created the protocol", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="created_protocols", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="treatmentprotocol", + name="tenant", + field=models.ForeignKey( + help_text="Organization tenant", + on_delete=django.db.models.deletion.CASCADE, + related_name="treatment_protocols", + to="core.tenant", + ), + ), migrations.AddField( model_name="vitalsigns", name="encounter", @@ -304,6 +360,72 @@ class Migration(migrations.Migration): to=settings.AUTH_USER_MODEL, ), ), + migrations.AddIndex( + model_name="allergyalert", + index=models.Index( + fields=["tenant", "resolved"], name="emr_allergy_tenant__da9219_idx" + ), + ), + migrations.AddIndex( + model_name="allergyalert", + index=models.Index( + fields=["patient", "resolved"], name="emr_allergy_patient_674c53_idx" + ), + ), + migrations.AddIndex( + model_name="allergyalert", + index=models.Index( + fields=["severity"], name="emr_allergy_severit_38d8dd_idx" + ), + ), + migrations.AddIndex( + model_name="allergyalert", + index=models.Index( + fields=["detected_at"], name="emr_allergy_detecte_97c184_idx" + ), + ), + migrations.AddIndex( + model_name="clinicalguideline", + index=models.Index( + fields=["tenant", "is_active"], name="emr_clinica_tenant__08a1f7_idx" + ), + ), + migrations.AddIndex( + model_name="clinicalguideline", + index=models.Index( + fields=["organization"], name="emr_clinica_organiz_107f8d_idx" + ), + ), + migrations.AddIndex( + model_name="clinicalguideline", + index=models.Index( + fields=["publication_date"], name="emr_clinica_publica_4e35d0_idx" + ), + ), + migrations.AddIndex( + model_name="diagnosticsuggestion", + index=models.Index( + fields=["tenant", "status"], name="emr_diagnos_tenant__77e6f8_idx" + ), + ), + migrations.AddIndex( + model_name="diagnosticsuggestion", + index=models.Index( + fields=["patient", "status"], name="emr_diagnos_patient_8c5470_idx" + ), + ), + migrations.AddIndex( + model_name="diagnosticsuggestion", + index=models.Index( + fields=["confidence"], name="emr_diagnos_confide_f7447d_idx" + ), + ), + migrations.AddIndex( + model_name="diagnosticsuggestion", + index=models.Index( + fields=["created_at"], name="emr_diagnos_created_34cc79_idx" + ), + ), migrations.AddIndex( model_name="encounter", index=models.Index( @@ -335,6 +457,31 @@ class Migration(migrations.Migration): fields=["start_datetime"], name="emr_encount_start_d_a01018_idx" ), ), + migrations.AddIndex( + model_name="criticalalert", + index=models.Index( + fields=["tenant", "acknowledged"], name="emr_critica_tenant__a7de09_idx" + ), + ), + migrations.AddIndex( + model_name="criticalalert", + index=models.Index( + fields=["patient", "acknowledged"], + name="emr_critica_patient_3f3d88_idx", + ), + ), + migrations.AddIndex( + model_name="criticalalert", + index=models.Index( + fields=["priority"], name="emr_critica_priorit_06ad08_idx" + ), + ), + migrations.AddIndex( + model_name="criticalalert", + index=models.Index( + fields=["created_at"], name="emr_critica_created_3acbe1_idx" + ), + ), migrations.AddIndex( model_name="notetemplate", index=models.Index( @@ -387,6 +534,36 @@ class Migration(migrations.Migration): fields=["onset_date"], name="emr_problem_onset_d_de94bd_idx" ), ), + migrations.AddIndex( + model_name="clinicalrecommendation", + index=models.Index( + fields=["tenant", "status"], name="emr_clinica_tenant__9ac4a3_idx" + ), + ), + migrations.AddIndex( + model_name="clinicalrecommendation", + index=models.Index( + fields=["patient", "status"], name="emr_clinica_patient_6a41b7_idx" + ), + ), + migrations.AddIndex( + model_name="clinicalrecommendation", + index=models.Index( + fields=["category"], name="emr_clinica_categor_44bb6e_idx" + ), + ), + migrations.AddIndex( + model_name="clinicalrecommendation", + index=models.Index( + fields=["priority"], name="emr_clinica_priorit_d52001_idx" + ), + ), + migrations.AddIndex( + model_name="clinicalrecommendation", + index=models.Index( + fields=["created_at"], name="emr_clinica_created_d816a2_idx" + ), + ), migrations.AddIndex( model_name="clinicalnote", index=models.Index( @@ -446,6 +623,18 @@ class Migration(migrations.Migration): fields=["priority"], name="emr_care_pl_priorit_0a41d3_idx" ), ), + migrations.AddIndex( + model_name="treatmentprotocol", + index=models.Index( + fields=["tenant", "is_active"], name="emr_treatme_tenant__1f3aaa_idx" + ), + ), + migrations.AddIndex( + model_name="treatmentprotocol", + index=models.Index( + fields=["success_rate"], name="emr_treatme_success_d8024a_idx" + ), + ), migrations.AddIndex( model_name="vitalsigns", index=models.Index( diff --git a/emr/migrations/0003_icd10_tenant_alter_icd10_code_and_more.py b/emr/migrations/0003_icd10_tenant_alter_icd10_code_and_more.py new file mode 100644 index 00000000..f907302f --- /dev/null +++ b/emr/migrations/0003_icd10_tenant_alter_icd10_code_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2.6 on 2025-09-28 13:55 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0001_initial"), + ("emr", "0002_initial"), + ] + + operations = [ + migrations.AddField( + model_name="icd10", + name="tenant", + field=models.ForeignKey( + default=1, + help_text="Organization tenant", + on_delete=django.db.models.deletion.CASCADE, + related_name="icd10_codes", + to="core.tenant", + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="icd10", + name="code", + field=models.CharField(db_index=True, max_length=10), + ), + migrations.AlterUniqueTogether( + name="icd10", + unique_together={("tenant", "code")}, + ), + ] diff --git a/emr/migrations/0004_alter_encounter_status.py b/emr/migrations/0004_alter_encounter_status.py new file mode 100644 index 00000000..40381aba --- /dev/null +++ b/emr/migrations/0004_alter_encounter_status.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.6 on 2025-09-29 13:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("emr", "0003_icd10_tenant_alter_icd10_code_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="encounter", + name="status", + field=models.CharField( + choices=[ + ("PLANNED", "Planned"), + ("ARRIVED", "Arrived"), + ("TRIAGED", "Triaged"), + ("IN_PROGRESS", "In Progress"), + ("ON_HOLD", "On Hold"), + ("COMPLETED", "Completed"), + ("CANCELLED", "Cancelled"), + ("ENTERED_IN_ERROR", "Entered in Error"), + ("UNKNOWN", "Unknown"), + ], + default="PLANNED", + help_text="Current encounter status", + max_length=20, + ), + ), + ] diff --git a/emr/migrations/__pycache__/0001_initial.cpython-312.pyc b/emr/migrations/__pycache__/0001_initial.cpython-312.pyc index 83dcf2dd..4bbea66d 100644 Binary files a/emr/migrations/__pycache__/0001_initial.cpython-312.pyc and b/emr/migrations/__pycache__/0001_initial.cpython-312.pyc differ diff --git a/emr/migrations/__pycache__/0002_initial.cpython-312.pyc b/emr/migrations/__pycache__/0002_initial.cpython-312.pyc index d7268a32..8c8aceb6 100644 Binary files a/emr/migrations/__pycache__/0002_initial.cpython-312.pyc and b/emr/migrations/__pycache__/0002_initial.cpython-312.pyc differ diff --git a/emr/migrations/__pycache__/0003_icd10_tenant_alter_icd10_code_and_more.cpython-312.pyc b/emr/migrations/__pycache__/0003_icd10_tenant_alter_icd10_code_and_more.cpython-312.pyc new file mode 100644 index 00000000..deb289bb Binary files /dev/null and b/emr/migrations/__pycache__/0003_icd10_tenant_alter_icd10_code_and_more.cpython-312.pyc differ diff --git a/emr/migrations/__pycache__/0004_alter_encounter_status.cpython-312.pyc b/emr/migrations/__pycache__/0004_alter_encounter_status.cpython-312.pyc new file mode 100644 index 00000000..d7860e94 Binary files /dev/null and b/emr/migrations/__pycache__/0004_alter_encounter_status.cpython-312.pyc differ diff --git a/emr/models.py b/emr/models.py index f12b421b..1100d9fc 100644 --- a/emr/models.py +++ b/emr/models.py @@ -10,12 +10,70 @@ from django.utils import timezone from django.conf import settings from datetime import timedelta, datetime, time import json +from django.core.exceptions import ValidationError +from django.db import models as django_models + + +class EncounterManager(django_models.Manager): + """ + Custom manager for Encounter model with common queries. + """ + + def active_encounters(self, tenant): + """Get all active encounters for a tenant.""" + return self.filter( + tenant=tenant, + status__in=['ARRIVED', 'TRIAGED', 'IN_PROGRESS', 'ON_HOLD'] + ) + + def encounters_by_provider(self, provider, tenant, days_back=30): + """Get encounters by provider within specified days.""" + start_date = timezone.now().date() - timedelta(days=days_back) + return self.filter( + tenant=tenant, + provider=provider, + start_datetime__date__gte=start_date + ).order_by('-start_datetime') + + def encounters_by_patient(self, patient, tenant): + """Get all encounters for a specific patient.""" + return self.filter( + tenant=tenant, + patient=patient + ).order_by('-start_datetime') + + def encounters_by_type(self, encounter_type, tenant, days_back=30): + """Get encounters by type within specified days.""" + start_date = timezone.now().date() - timedelta(days=days_back) + return self.filter( + tenant=tenant, + encounter_type=encounter_type, + start_datetime__date__gte=start_date + ).order_by('-start_datetime') + + def todays_encounters(self, tenant): + """Get today's encounters for a tenant.""" + today = timezone.now().date() + return self.filter( + tenant=tenant, + start_datetime__date=today + ).order_by('-start_datetime') + + def unsigned_encounters(self, tenant): + """Get encounters that need signing off.""" + return self.filter( + tenant=tenant, + status='FINISHED', + signed_off=False + ).order_by('-end_datetime') class Encounter(models.Model): """ Clinical encounter model for tracking patient visits and care episodes. """ + objects = EncounterManager() + class EncounterType(models.TextChoices): INPATIENT = 'INPATIENT', 'Inpatient' OUTPATIENT = 'OUTPATIENT', 'Outpatient' @@ -50,7 +108,7 @@ class Encounter(models.Model): TRIAGED = 'TRIAGED', 'Triaged' IN_PROGRESS = 'IN_PROGRESS', 'In Progress' ON_HOLD = 'ON_HOLD', 'On Hold' - FINISHED = 'FINISHED', 'Finished' + COMPLETED = 'COMPLETED', 'Completed' CANCELLED = 'CANCELLED', 'Cancelled' ENTERED_IN_ERROR = 'ENTERED_IN_ERROR', 'Entered in Error' UNKNOWN = 'UNKNOWN', 'Unknown' @@ -269,6 +327,24 @@ class Encounter(models.Model): """ return self.status in ['ARRIVED', 'TRIAGED', 'IN_PROGRESS', 'ON_HOLD'] + def get_status_color(self): + """ + Get Bootstrap color class for status display. + """ + + status_colors = { + 'PLANNED': 'danger', + 'ARRIVED': 'warning', + 'TRIAGED': 'success', + 'IN_PROGRESS': 'info', + 'ON_HOLD': 'warning', + 'COMPLETED': 'warning', + 'CANCELLED': 'secondary', + 'ENTERED_IN_ERROR': 'secondary', + 'UNKNOWN': 'primary', + } + return status_colors.get(self.status, 'secondary') + class VitalSigns(models.Model): """ @@ -550,15 +626,52 @@ class VitalSigns(models.Model): def __str__(self): return f"{self.patient.get_full_name()} - {self.measured_datetime.strftime('%Y-%m-%d %H:%M')}" + def clean(self): + """ + Custom validation for VitalSigns model. + """ + # Validate blood pressure + if (self.systolic_bp and not self.diastolic_bp) or (self.diastolic_bp and not self.systolic_bp): + raise ValidationError('Both systolic and diastolic blood pressure must be provided together.') + + if self.systolic_bp and self.diastolic_bp and self.systolic_bp <= self.diastolic_bp: + raise ValidationError('Systolic blood pressure must be greater than diastolic blood pressure.') + + # Validate oxygen saturation and delivery + if self.oxygen_delivery and self.oxygen_delivery != 'ROOM_AIR' and not self.oxygen_flow_rate: + raise ValidationError('Oxygen flow rate is required when oxygen delivery method is specified.') + + # Validate pain scale + if self.pain_scale and (self.pain_scale < 0 or self.pain_scale > 10): + raise ValidationError('Pain scale must be between 0 and 10.') + + # Validate temperature + if self.temperature and (self.temperature < 30 or self.temperature > 45): + raise ValidationError('Temperature must be between 30°C and 45°C.') + + # Validate heart rate + if self.heart_rate and (self.heart_rate < 20 or self.heart_rate > 300): + raise ValidationError('Heart rate must be between 20 and 300 bpm.') + + # Validate respiratory rate + if self.respiratory_rate and (self.respiratory_rate < 5 or self.respiratory_rate > 60): + raise ValidationError('Respiratory rate must be between 5 and 60 breaths per minute.') + + # Validate oxygen saturation + if self.oxygen_saturation and (self.oxygen_saturation < 50 or self.oxygen_saturation > 100): + raise ValidationError('Oxygen saturation must be between 50% and 100%.') + def save(self, *args, **kwargs): """ Calculate BMI if weight and height are provided. """ + self.full_clean() # Run validation before saving + if self.weight and self.height: # BMI = (weight in pounds / (height in inches)^2) * 703 self.bmi = (self.weight / (self.height ** 2)) * 703 super().save(*args, **kwargs) - + @property def blood_pressure(self): """ @@ -567,7 +680,7 @@ class VitalSigns(models.Model): if self.systolic_bp and self.diastolic_bp: return f"{self.systolic_bp}/{self.diastolic_bp}" return None - + @property def has_critical_values(self): """ @@ -575,6 +688,46 @@ class VitalSigns(models.Model): """ return len(self.critical_values) > 0 + @property + def is_normal_temperature(self): + """ + Check if temperature is within normal range (36.1°C - 37.2°C). + """ + return self.temperature and 36.1 <= self.temperature <= 37.2 + + @property + def is_normal_blood_pressure(self): + """ + Check if blood pressure is within normal range (90/60 - 120/80). + """ + if not (self.systolic_bp and self.diastolic_bp): + return None + return 90 <= self.systolic_bp <= 140 and 60 <= self.diastolic_bp <= 90 + + @property + def is_normal_oxygen_saturation(self): + """ + Check if oxygen saturation is normal (>= 95%). + """ + return self.oxygen_saturation and self.oxygen_saturation >= 95 + + def get_vital_signs_summary(self): + """ + Get a summary of vital signs for display. + """ + summary = [] + if self.temperature: + summary.append(f"T: {self.temperature}°C") + if self.blood_pressure: + summary.append(f"BP: {self.blood_pressure}") + if self.heart_rate: + summary.append(f"HR: {self.heart_rate}") + if self.respiratory_rate: + summary.append(f"RR: {self.respiratory_rate}") + if self.oxygen_saturation: + summary.append(f"SpO2: {self.oxygen_saturation}%") + return " | ".join(summary) if summary else "No vital signs recorded" + class ProblemList(models.Model): """ @@ -850,6 +1003,29 @@ class ProblemList(models.Model): """ return self.status == 'ACTIVE' + def clean(self): + """ + Custom validation for ProblemList model. + """ + # Validate resolution date is after onset date + if self.onset_date and self.resolution_date and self.resolution_date < self.onset_date: + raise ValidationError('Resolution date cannot be before onset date.') + + # Validate resolution date is not in the future for resolved problems + if self.status in ['RESOLVED', 'REMISSION'] and self.resolution_date and self.resolution_date > timezone.now().date(): + raise ValidationError('Resolution date cannot be in the future.') + + # Validate onset date is not in the future + if self.onset_date and self.onset_date > timezone.now().date(): + raise ValidationError('Onset date cannot be in the future.') + + def save(self, *args, **kwargs): + """ + Override save to perform custom validation. + """ + self.full_clean() + super().save(*args, **kwargs) + @property def duration(self): """ @@ -860,6 +1036,58 @@ class ProblemList(models.Model): return end_date - self.onset_date return None + @property + def duration_days(self): + """ + Get duration in days. + """ + duration = self.duration + return duration.days if duration else None + + @property + def is_chronic(self): + """ + Check if problem is considered chronic (duration > 90 days). + """ + duration = self.duration + return duration and duration.days > 90 + + @property + def can_be_resolved(self): + """ + Check if problem can still be resolved. + """ + return self.status in ['ACTIVE', 'RECURRENCE', 'RELAPSE'] + + def get_status_color(self): + """ + Get Bootstrap color class for status display. + """ + status_colors = { + 'ACTIVE': 'danger', + 'INACTIVE': 'warning', + 'RESOLVED': 'success', + 'REMISSION': 'info', + 'RECURRENCE': 'warning', + 'RELAPSE': 'warning', + 'UNKNOWN': 'secondary', + 'OTHER': 'secondary', + } + return status_colors.get(self.status, 'secondary') + + def get_priority_color(self): + """ + Get Bootstrap color class for priority display. + """ + + priority_colors = { + 'LOW': 'success', + 'MEDIUM': 'info', + 'HIGH': 'warning', + 'URGENT': 'danger', + } + return priority_colors.get(self.priority, 'secondary') + class CarePlan(models.Model): """ @@ -898,7 +1126,7 @@ class CarePlan(models.Model): ON_HOLD = 'ON_HOLD', 'On Hold' COMPLETED = 'COMPLETED', 'Completed' CANCELLED = 'CANCELLED', 'Cancelled' - ENTERED_IN_ERROR = 'ENTERED_IN_ERROR', 'Entered in Error' + ERROR = 'ERROR', 'Entered in Error' UNKNOWN = 'UNKNOWN', 'Unknown' class PlanPriority(models.TextChoices): @@ -1148,6 +1376,47 @@ class CarePlan(models.Model): """ return self.status == 'ACTIVE' + def clean(self): + """ + Custom validation for CarePlan model. + """ + # Validate date ranges + if self.end_date and self.start_date and self.end_date < self.start_date: + raise ValidationError('End date cannot be before start date.') + + if self.target_completion_date and self.start_date and self.target_completion_date < self.start_date: + raise ValidationError('Target completion date cannot be before start date.') + + if self.next_review_date and self.start_date and self.next_review_date < self.start_date: + raise ValidationError('Next review date cannot be before start date.') + + # Validate completion percentage + if self.completion_percentage < 0 or self.completion_percentage > 100: + raise ValidationError('Completion percentage must be between 0 and 100.') + + # Validate status-specific logic + if self.status == 'COMPLETED' and self.completion_percentage != 100: + raise ValidationError('Completed care plans must have 100% completion.') + + if self.status == 'COMPLETED' and not self.end_date: + raise ValidationError('Completed care plans must have an end date.') + + def save(self, *args, **kwargs): + """ + Override save to perform custom validation and business logic. + """ + self.full_clean() + + # Auto-set end_date for completed plans + if self.status == 'COMPLETED' and not self.end_date: + self.end_date = timezone.now().date() + + # Auto-set completion percentage for completed plans + if self.status == 'COMPLETED' and self.completion_percentage != 100: + self.completion_percentage = 100 + + super().save(*args, **kwargs) + @property def is_overdue(self): """ @@ -1157,6 +1426,77 @@ class CarePlan(models.Model): return timezone.now().date() > self.next_review_date return False + @property + def days_remaining(self): + """ + Calculate days remaining until target completion. + """ + if self.target_completion_date: + return (self.target_completion_date - timezone.now().date()).days + return None + + @property + def duration_days(self): + """ + Calculate total planned duration in days. + """ + if self.start_date and self.end_date: + return (self.end_date - self.start_date).days + return None + + @property + def progress_percentage(self): + """ + Calculate progress percentage based on time elapsed. + """ + if not self.start_date or not self.end_date: + return None + + total_days = (self.end_date - self.start_date).days + if total_days <= 0: + return 100 + + elapsed_days = (timezone.now().date() - self.start_date).days + if elapsed_days <= 0: + return 0 + + return min(100, int((elapsed_days / total_days) * 100)) + + @property + def can_be_completed(self): + """ + Check if care plan can be marked as completed. + """ + return self.status in ['ACTIVE', 'ON_HOLD'] + + def get_status_color(self): + """ + Get Bootstrap color class for status display. + """ + status_colors = { + 'DRAFT': 'secondary', + 'ACTIVE': 'success', + 'ON_HOLD': 'warning', + 'COMPLETED': 'primary', + 'CANCELLED': 'danger', + 'ERROR': 'danger', + 'UNKNOWN': 'secondary', + } + return status_colors.get(self.status, 'secondary') + + def update_progress(self, new_percentage, notes=None): + """ + Update care plan progress. + """ + if 0 <= new_percentage <= 100: + self.completion_percentage = new_percentage + if notes: + self.progress_notes = notes + self.last_reviewed = timezone.now().date() + self.save() + return True + return False + class ClinicalNote(models.Model): """ @@ -1578,7 +1918,15 @@ class Icd10(models.Model): ICD-10-CM tabular code entry. Handles chapters/sections/diagnoses (with parent-child hierarchy). """ - code = models.CharField(max_length=10, unique=True, db_index=True) + # Tenant relationship + tenant = models.ForeignKey( + 'core.Tenant', + on_delete=models.CASCADE, + related_name='icd10_codes', + help_text='Organization tenant' + ) + + code = models.CharField(max_length=10, db_index=True) description = models.TextField(blank=True, null=True) chapter_name = models.CharField(max_length=255, blank=True, null=True) section_name = models.CharField(max_length=255, blank=True, null=True) @@ -1598,6 +1946,766 @@ class Icd10(models.Model): verbose_name = 'ICD-10 Code' verbose_name_plural = 'ICD-10 Codes' ordering = ['code'] + unique_together = ['tenant', 'code'] def __str__(self): - return f"{self.code} — {self.description[:80] if self.description else ''}" \ No newline at end of file + return f"{self.code} — {self.description[:80] if self.description else ''}" + + +class ClinicalRecommendation(models.Model): + """ + Clinical recommendation model for AI-powered clinical decision support. + """ + + class RecommendationCategory(models.TextChoices): + PREVENTIVE = 'PREVENTIVE', 'Preventive Care' + DIAGNOSTIC = 'DIAGNOSTIC', 'Diagnostic' + TREATMENT = 'TREATMENT', 'Treatment' + MONITORING = 'MONITORING', 'Monitoring' + LIFESTYLE = 'LIFESTYLE', 'Lifestyle' + MEDICATION = 'MEDICATION', 'Medication' + FOLLOW_UP = 'FOLLOW_UP', 'Follow-up' + REFERRAL = 'REFERRAL', 'Referral' + EDUCATION = 'EDUCATION', 'Patient Education' + OTHER = 'OTHER', 'Other' + + class RecommendationPriority(models.TextChoices): + LOW = 'LOW', 'Low' + MEDIUM = 'MEDIUM', 'Medium' + HIGH = 'HIGH', 'High' + URGENT = 'URGENT', 'Urgent' + CRITICAL = 'CRITICAL', 'Critical' + + class RecommendationStatus(models.TextChoices): + PENDING = 'PENDING', 'Pending' + ACTIVE = 'ACTIVE', 'Active' + ACCEPTED = 'ACCEPTED', 'Accepted' + DEFERRED = 'DEFERRED', 'Deferred' + DISMISSED = 'DISMISSED', 'Dismissed' + COMPLETED = 'COMPLETED', 'Completed' + EXPIRED = 'EXPIRED', 'Expired' + + # Tenant relationship + tenant = models.ForeignKey( + 'core.Tenant', + on_delete=models.CASCADE, + related_name='clinical_recommendations', + help_text='Organization tenant' + ) + + # Patient relationship + patient = models.ForeignKey( + 'patients.PatientProfile', + on_delete=models.CASCADE, + related_name='clinical_recommendations', + help_text='Patient' + ) + + # Recommendation details + recommendation_id = models.UUIDField( + default=uuid.uuid4, + unique=True, + editable=False, + help_text='Unique recommendation identifier' + ) + + title = models.CharField( + max_length=200, + help_text='Recommendation title' + ) + description = models.TextField( + help_text='Detailed recommendation description' + ) + + # Classification + category = models.CharField( + max_length=20, + choices=RecommendationCategory.choices, + help_text='Recommendation category' + ) + priority = models.CharField( + max_length=20, + choices=RecommendationPriority.choices, + default=RecommendationPriority.MEDIUM, + help_text='Recommendation priority' + ) + + # Clinical details + evidence_level = models.CharField( + max_length=10, + help_text='Level of evidence (1A, 1B, 2A, etc.)' + ) + source = models.CharField( + max_length=100, + help_text='Source of recommendation (guideline, study, etc.)' + ) + rationale = models.TextField( + blank=True, + null=True, + help_text='Clinical rationale for recommendation' + ) + + # Status and actions + status = models.CharField( + max_length=20, + choices=RecommendationStatus.choices, + default=RecommendationStatus.PENDING, + help_text='Current recommendation status' + ) + + # Action tracking + accepted_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='accepted_recommendations', + help_text='Provider who accepted the recommendation' + ) + accepted_at = models.DateTimeField( + blank=True, + null=True, + help_text='Date and time recommendation was accepted' + ) + + deferred_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='deferred_recommendations', + help_text='Provider who deferred the recommendation' + ) + deferred_at = models.DateTimeField( + blank=True, + null=True, + help_text='Date and time recommendation was deferred' + ) + + dismissed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='dismissed_recommendations', + help_text='Provider who dismissed the recommendation' + ) + dismissed_at = models.DateTimeField( + blank=True, + null=True, + help_text='Date and time recommendation was dismissed' + ) + + # Timing + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + expires_at = models.DateTimeField( + blank=True, + null=True, + help_text='Recommendation expiration date' + ) + + # Related data + related_problems = models.ManyToManyField( + ProblemList, + related_name='recommendations', + blank=True, + help_text='Related problems' + ) + related_encounter = models.ForeignKey( + Encounter, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='recommendations', + help_text='Related encounter' + ) + + # Metadata + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='created_recommendations', + help_text='User who created the recommendation' + ) + + class Meta: + db_table = 'emr_clinical_recommendation' + verbose_name = 'Clinical Recommendation' + verbose_name_plural = 'Clinical Recommendations' + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['tenant', 'status']), + models.Index(fields=['patient', 'status']), + models.Index(fields=['category']), + models.Index(fields=['priority']), + models.Index(fields=['created_at']), + ] + + def __str__(self): + return f"{self.patient.get_full_name()} - {self.title}" + + @property + def is_expired(self): + """ + Check if recommendation has expired. + """ + if self.expires_at: + return timezone.now() > self.expires_at + return False + + @property + def is_active(self): + """ + Check if recommendation is currently active. + """ + return self.status in ['PENDING', 'ACTIVE'] and not self.is_expired + + +class AllergyAlert(models.Model): + """ + Allergy alert model for tracking patient allergies and reactions. + """ + + class AlertSeverity(models.TextChoices): + MILD = 'MILD', 'Mild' + MODERATE = 'MODERATE', 'Moderate' + SEVERE = 'SEVERE', 'Severe' + LIFE_THREATENING = 'LIFE_THREATENING', 'Life-threatening' + + # Tenant relationship + tenant = models.ForeignKey( + 'core.Tenant', + on_delete=models.CASCADE, + related_name='allergy_alerts', + help_text='Organization tenant' + ) + + # Patient relationship + patient = models.ForeignKey( + 'patients.PatientProfile', + on_delete=models.CASCADE, + related_name='allergy_alerts', + help_text='Patient' + ) + + # Alert details + alert_id = models.UUIDField( + default=uuid.uuid4, + unique=True, + editable=False, + help_text='Unique alert identifier' + ) + + allergen = models.CharField( + max_length=100, + help_text='Allergen name' + ) + + reaction_type = models.CharField( + max_length=100, + help_text='Type of allergic reaction' + ) + + severity = models.CharField( + max_length=20, + choices=AlertSeverity.choices, + help_text='Alert severity' + ) + + # Clinical details + symptoms = models.TextField( + blank=True, + null=True, + help_text='Allergic reaction symptoms' + ) + onset = models.CharField( + max_length=50, + blank=True, + null=True, + help_text='Reaction onset timing' + ) + + # Status + resolved = models.BooleanField( + default=False, + help_text='Alert has been resolved' + ) + resolved_at = models.DateTimeField( + blank=True, + null=True, + help_text='Date alert was resolved' + ) + resolved_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='resolved_allergy_alerts', + help_text='Provider who resolved the alert' + ) + + # Metadata + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + detected_at = models.DateTimeField( + default=timezone.now, + help_text='When alert was detected' + ) + + class Meta: + db_table = 'emr_allergy_alert' + verbose_name = 'Allergy Alert' + verbose_name_plural = 'Allergy Alerts' + ordering = ['-detected_at'] + indexes = [ + models.Index(fields=['tenant', 'resolved']), + models.Index(fields=['patient', 'resolved']), + models.Index(fields=['severity']), + models.Index(fields=['detected_at']), + ] + + def __str__(self): + return f"{self.patient.get_full_name()} - {self.allergen}" + + +class TreatmentProtocol(models.Model): + """ + Treatment protocol model for standardized treatment approaches. + """ + + # Tenant relationship + tenant = models.ForeignKey( + 'core.Tenant', + on_delete=models.CASCADE, + related_name='treatment_protocols', + help_text='Organization tenant' + ) + + # Protocol details + protocol_id = models.UUIDField( + default=uuid.uuid4, + unique=True, + editable=False, + help_text='Unique protocol identifier' + ) + + name = models.CharField( + max_length=200, + help_text='Protocol name' + ) + + description = models.TextField( + help_text='Protocol description' + ) + + # Clinical details + indication = models.TextField( + help_text='Clinical indications for use' + ) + + goals = models.JSONField( + default=list, + help_text='Treatment goals' + ) + + interventions = models.JSONField( + default=list, + help_text='Required interventions' + ) + + monitoring_parameters = models.JSONField( + default=list, + help_text='Parameters to monitor' + ) + + # Effectiveness + success_rate = models.DecimalField( + max_digits=5, + decimal_places=2, + help_text='Protocol success rate (%)' + ) + + average_duration = models.PositiveIntegerField( + help_text='Average treatment duration in days' + ) + + # Status + is_active = models.BooleanField( + default=True, + help_text='Protocol is active and available' + ) + + # Usage tracking + usage_count = models.PositiveIntegerField( + default=0, + help_text='Number of times protocol has been used' + ) + + # Metadata + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='created_protocols', + help_text='User who created the protocol' + ) + + class Meta: + db_table = 'emr_treatment_protocol' + verbose_name = 'Treatment Protocol' + verbose_name_plural = 'Treatment Protocols' + ordering = ['-success_rate', 'name'] + indexes = [ + models.Index(fields=['tenant', 'is_active']), + models.Index(fields=['success_rate']), + ] + + def __str__(self): + return f"{self.name} ({self.success_rate}% success)" + + +class ClinicalGuideline(models.Model): + """ + Clinical guideline model for referencing medical guidelines. + """ + + # Tenant relationship + tenant = models.ForeignKey( + 'core.Tenant', + on_delete=models.CASCADE, + related_name='clinical_guidelines', + help_text='Organization tenant' + ) + + # Guideline details + guideline_id = models.UUIDField( + default=uuid.uuid4, + unique=True, + editable=False, + help_text='Unique guideline identifier' + ) + + title = models.CharField( + max_length=300, + help_text='Guideline title' + ) + + organization = models.CharField( + max_length=100, + help_text='Publishing organization' + ) + + summary = models.TextField( + help_text='Guideline summary' + ) + + url = models.URLField( + blank=True, + null=True, + help_text='Link to full guideline' + ) + + # Publication details + publication_date = models.DateField( + help_text='Guideline publication date' + ) + + last_updated = models.DateField( + auto_now=True, + help_text='Last updated date' + ) + + version = models.CharField( + max_length=20, + blank=True, + null=True, + help_text='Guideline version' + ) + + # Status + is_active = models.BooleanField( + default=True, + help_text='Guideline is current and active' + ) + + # Relevance + keywords = models.JSONField( + default=list, + help_text='Keywords for searching' + ) + + specialties = models.JSONField( + default=list, + help_text='Relevant medical specialties' + ) + + # Metadata + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'emr_clinical_guideline' + verbose_name = 'Clinical Guideline' + verbose_name_plural = 'Clinical Guidelines' + ordering = ['-last_updated', 'title'] + indexes = [ + models.Index(fields=['tenant', 'is_active']), + models.Index(fields=['organization']), + models.Index(fields=['publication_date']), + ] + + def __str__(self): + return f"{self.title} - {self.organization}" + + +class CriticalAlert(models.Model): + """ + Critical alert model for high-priority clinical alerts. + """ + + class AlertPriority(models.TextChoices): + HIGH = 'HIGH', 'High' + URGENT = 'URGENT', 'Urgent' + CRITICAL = 'CRITICAL', 'Critical' + + # Tenant relationship + tenant = models.ForeignKey( + 'core.Tenant', + on_delete=models.CASCADE, + related_name='critical_alerts', + help_text='Organization tenant' + ) + + # Patient relationship + patient = models.ForeignKey( + 'patients.PatientProfile', + on_delete=models.CASCADE, + related_name='critical_alerts', + help_text='Patient' + ) + + # Alert details + alert_id = models.UUIDField( + default=uuid.uuid4, + unique=True, + editable=False, + help_text='Unique alert identifier' + ) + + title = models.CharField( + max_length=200, + help_text='Alert title' + ) + + description = models.TextField( + help_text='Alert description' + ) + + priority = models.CharField( + max_length=20, + choices=AlertPriority.choices, + default=AlertPriority.HIGH, + help_text='Alert priority level' + ) + + # Clinical details + recommendation = models.TextField( + blank=True, + null=True, + help_text='Recommended actions' + ) + + # Status + acknowledged = models.BooleanField( + default=False, + help_text='Alert has been acknowledged' + ) + + acknowledged_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='emr_acknowledged_alerts', + help_text='Provider who acknowledged the alert' + ) + + acknowledged_at = models.DateTimeField( + blank=True, + null=True, + help_text='Date and time alert was acknowledged' + ) + + # Timing + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + expires_at = models.DateTimeField( + blank=True, + null=True, + help_text='Alert expiration date' + ) + + # Related data + related_encounter = models.ForeignKey( + Encounter, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='critical_alerts', + help_text='Related encounter' + ) + + # Metadata + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='created_alerts', + help_text='User who created the alert' + ) + + class Meta: + db_table = 'emr_critical_alert' + verbose_name = 'Critical Alert' + verbose_name_plural = 'Critical Alerts' + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['tenant', 'acknowledged']), + models.Index(fields=['patient', 'acknowledged']), + models.Index(fields=['priority']), + models.Index(fields=['created_at']), + ] + + def __str__(self): + return f"{self.patient.get_full_name()} - {self.title}" + + @property + def is_expired(self): + """ + Check if alert has expired. + """ + if self.expires_at: + return timezone.now() > self.expires_at + return False + + @property + def is_active(self): + """ + Check if alert is currently active. + """ + return not self.acknowledged and not self.is_expired + + +class DiagnosticSuggestion(models.Model): + """ + Diagnostic suggestion model for AI-suggested diagnostic tests. + """ + + class Status(models.TextChoices): + PENDING = 'PENDING', 'Pending' + ORDERED = 'ORDERED', 'Ordered' + COMPLETED = 'COMPLETED', 'Completed' + CANCELLED = 'CANCELLED', 'Cancelled' + + # Tenant relationship + tenant = models.ForeignKey( + 'core.Tenant', + on_delete=models.CASCADE, + related_name='diagnostic_suggestions', + help_text='Organization tenant' + ) + + # Patient relationship + patient = models.ForeignKey( + 'patients.PatientProfile', + on_delete=models.CASCADE, + related_name='diagnostic_suggestions', + help_text='Patient' + ) + + # Suggestion details + suggestion_id = models.UUIDField( + default=uuid.uuid4, + unique=True, + editable=False, + help_text='Unique suggestion identifier' + ) + + test_name = models.CharField( + max_length=200, + help_text='Suggested test name' + ) + + test_code = models.CharField( + max_length=20, + help_text='Test code or identifier' + ) + + indication = models.TextField( + help_text='Clinical indication for the test' + ) + + confidence = models.DecimalField( + max_digits=5, + decimal_places=2, + validators=[MinValueValidator(0), MaxValueValidator(100)], + help_text='AI confidence score (%)' + ) + + # Status + status = models.CharField( + max_length=20, + choices=Status.choices, + default=Status.ORDERED, + help_text='Suggestion status' + ) + + # Ordering information + ordered_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='ordered_suggestions', + help_text='Provider who ordered the test' + ) + + ordered_at = models.DateTimeField( + blank=True, + null=True, + help_text='Date and time test was ordered' + ) + + # Metadata + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='created_suggestions', + help_text='User who created the suggestion' + ) + + class Meta: + db_table = 'emr_diagnostic_suggestion' + verbose_name = 'Diagnostic Suggestion' + verbose_name_plural = 'Diagnostic Suggestions' + ordering = ['-confidence', '-created_at'] + indexes = [ + models.Index(fields=['tenant', 'status']), + models.Index(fields=['patient', 'status']), + models.Index(fields=['confidence']), + models.Index(fields=['created_at']), + ] + + def __str__(self): + return f"{self.patient.get_full_name()} - {self.test_name} ({self.confidence}%)" diff --git a/emr/templates/emr/care_plans/care_plan_list.html b/emr/templates/emr/care_plans/care_plan_list.html index 6527fcc5..8665f402 100644 --- a/emr/templates/emr/care_plans/care_plan_list.html +++ b/emr/templates/emr/care_plans/care_plan_list.html @@ -104,30 +104,45 @@ {% endif %} - + - {% for encounter in encounters %} + {% for encounter in recent_encounters %} @@ -326,7 +425,9 @@ +{% endblock %} +{% block js %} - - {% endblock %} - diff --git a/emr/templates/emr/encounter_detail.html b/emr/templates/emr/encounter_detail.html index 8e542ebc..44614b68 100644 --- a/emr/templates/emr/encounter_detail.html +++ b/emr/templates/emr/encounter_detail.html @@ -268,26 +268,54 @@
Clinical Notes
- {% for note in clinical_notes %} -
-
-
-
{{ note.title }}
- - {{ note.get_note_type_display }} • - {{ note.note_datetime|date:"M d, Y H:i" }} • - {{ note.author.get_full_name }} - -
- - {{ note.get_status_display }} - -
-
-

{{ note.content|truncatewords:50|linebreaks }}

-
+
+
+ Pos.PatientDepartmentSpecialtyPriorityUrgencyStatusWait TimeLast ContactActionsPos.PatientDepartmentSpecialtyPriorityUrgencyStatusWait TimeLast ContactActions
- {% if plan.status == 'ACTIVE' %} - Active - {% elif plan.status == 'COMPLETED' %} - Completed - {% elif plan.status == 'ON_HOLD' %} - On Hold - {% elif plan.status == 'CANCELLED' %} - Cancelled - {% elif plan.status == 'DRAFT' %} - Draft + + {{ plan.get_status_display }} + + {% if plan.is_active %} +
+ Currently Active + {% endif %} -
- {{ plan.start_date|date:"M d, Y" }} - {% if plan.target_end_date %} -
- Target: {{ plan.target_end_date|date:"M d, Y" }} + {% if plan.is_overdue %} +
+ Overdue {% endif %}
- {% if plan.completion_percentage %} + {{ plan.start_date|date:"M d, Y" }} + {% if plan.end_date %} +
+ End: {{ plan.end_date|date:"M d, Y" }} + + {% elif plan.target_completion_date %} +
+ Target: {{ plan.target_completion_date|date:"M d, Y" }} + + {% endif %} + {% if plan.days_remaining %} +
+ {{ plan.days_remaining }} days remaining + + {% endif %} +
+ {% if plan.completion_percentage > 0 %}
-
+ {% if plan.progress_percentage %} + + Time progress: {{ plan.progress_percentage }}% + + {% endif %} {% else %} Not started {% endif %} @@ -188,4 +208,3 @@
{% endblock %} - diff --git a/emr/templates/emr/clinical_decision_support.html b/emr/templates/emr/clinical_decision_support.html index 966c0174..9bae32f0 100644 --- a/emr/templates/emr/clinical_decision_support.html +++ b/emr/templates/emr/clinical_decision_support.html @@ -2,16 +2,105 @@ {% load static %} {% block title %}Clinical Decision Support{% endblock %} +{% block css %} + + +{% endblock %} {% block content %} -
-
- - +

Clinical Decision Support

@@ -66,7 +155,7 @@

Patient Summary

@@ -416,7 +505,7 @@
{% endif %}
-
+
@@ -155,17 +240,24 @@
{{ encounter.start_datetime|date:"M d, Y" }}
{{ encounter.start_datetime|time:"H:i" }} + {% if encounter.duration %} +
Duration: {{ encounter.duration }} + {% endif %}
- + {{ encounter.get_status_display }} + {% if encounter.is_active %} +
Active + {% endif %}
+ {% if encounter.can_be_edited %}
+ + + + + + + + + + + + + {% for note in clinical_notes %} + + + + + + + + + + {% endfor %} + +
Date/TimeTitleTypeAuthorStatusContent PreviewActions
{{ note.note_datetime|date:"M d, Y H:i" }}{{ note.title }}{{ note.get_note_type_display }}{{ note.author.get_full_name }} + + {{ note.get_status_display }} + + {% if note.electronically_signed %} + + {% endif %} + +
+ {{ note.content|truncatewords:15 }} +
+
+ + + + {% if note.status == 'DRAFT' and note.author == request.user %} + + + + {% endif %} +
- {% endfor %}
@@ -384,4 +412,3 @@
{% endblock %} - diff --git a/emr/templates/emr/encounter_list.html b/emr/templates/emr/encounter_list.html index 83000f97..9b8a4d41 100644 --- a/emr/templates/emr/encounter_list.html +++ b/emr/templates/emr/encounter_list.html @@ -162,27 +162,21 @@ value="{{ encounter.id }}">
- -
-
- - {{ encounter.patient.first_name.0 }}{{ encounter.patient.last_name.0 }} - -
-
-
{{ encounter.patient.get_full_name }}
- MRN: {{ encounter.patient.mrn }} -
-
- - - {{ encounter.get_encounter_type_display }} - - {{ encounter.provider.get_full_name }} - -
{{ encounter.start_datetime|date:"M d, Y" }}
- {{ encounter.start_datetime|time:"H:i" }} - + + + {{ encounter.get_status_display }} + + {% if encounter.is_active %} +
+ Active + + {% endif %} + {% if encounter.duration %} +
+ Duration: {{ encounter.duration }} + + {% endif %} + {{ encounter.get_status_display }} @@ -482,4 +476,3 @@ document.addEventListener('keydown', function(e) { } {% endblock %} - diff --git a/emr/templates/emr/encounters/encounter_detail.html b/emr/templates/emr/encounters/encounter_detail.html index ecf27b1e..d6dca332 100644 --- a/emr/templates/emr/encounters/encounter_detail.html +++ b/emr/templates/emr/encounters/encounter_detail.html @@ -33,9 +33,7 @@

Encounter Information

- - Edit - + Edit @@ -205,14 +203,11 @@
- +
-

Vital Signs

+

Encounter Details

- - Add Vitals - @@ -220,163 +215,509 @@
- {% if object.vital_signs.exists %} -
- - - - - - - - - - - - - - - - {% for vital in object.vital_signs.all %} - - - - - - - - - - - - {% endfor %} - -
TimeTempBPHRRRO2 SatPainMeasured ByActions
{{ vital.measured_datetime|time:"H:i" }} - {% if vital.temperature %} - {{ vital.temperature }}°F - {% else %} - - - {% endif %} - - {% if vital.blood_pressure %} - {{ vital.blood_pressure }} - {% else %} - - - {% endif %} - - {% if vital.heart_rate %} - {{ vital.heart_rate }} bpm - {% else %} - - - {% endif %} - - {% if vital.respiratory_rate %} - {{ vital.respiratory_rate }} /min - {% else %} - - - {% endif %} - - {% if vital.oxygen_saturation %} - {{ vital.oxygen_saturation }}% - {% else %} - - - {% endif %} - - {% if vital.pain_scale %} - {{ vital.pain_scale }}/10 - {% else %} - - - {% endif %} - {{ vital.measured_by.get_full_name }} - - - -
-
- {% else %} -
- -

No vital signs recorded for this encounter.

- - Add Vital Signs - -
- {% endif %} -
-
- - - -
-
-

Clinical Notes

-
- - Add Note - - - - - -
-
-
- {% if object.clinical_notes.exists %} -
- {% for note in object.clinical_notes.all %} -
-
{{ note.note_datetime|time:"H:i" }}
-
- + + + + + +
+ +
+
+
Vital Signs
+ + Add Vitals +
-
-
- - {{ note.title }} - - {% if note.status == 'DRAFT' %} - - {% elif note.status == 'IN_PROGRESS' %} - - {% elif note.status == 'COMPLETED' %} - - {% elif note.status == 'REVIEWED' %} - - {% elif note.status == 'AMENDED' %} - - {% elif note.status == 'CANCELLED' %} - - {% elif note.status == 'SIGNED' %} - - {% else %} - - {% endif %} - {{ note.get_status_display }} -
-
-
- {{ note.get_note_type_display }} by {{ note.author.get_full_name }} - {% if note.electronically_signed %} - - {% endif %} -
-{#
#} -{# {{ note.content }}#} -{#
#} -
+ + {% if object.vital_signs.exists %} +
+ + + + + + + + + + + + + + + + {% for vital in object.vital_signs.all %} + + + + + + + + + + + + {% endfor %} + +
TimeTempBPHRRRO2 SatPainMeasured ByActions
{{ vital.measured_datetime|time:"H:i" }} + {% if vital.temperature %} + {{ vital.temperature }}°F + {% else %} + - + {% endif %} + + {% if vital.blood_pressure %} + {{ vital.blood_pressure }} + {% else %} + - + {% endif %} + + {% if vital.heart_rate %} + {{ vital.heart_rate }} bpm + {% else %} + - + {% endif %} + + {% if vital.respiratory_rate %} + {{ vital.respiratory_rate }} /min + {% else %} + - + {% endif %} + + {% if vital.oxygen_saturation %} + {{ vital.oxygen_saturation }}% + {% else %} + - + {% endif %} + + {% if vital.pain_scale %} + {{ vital.pain_scale }}/10 + {% else %} + - + {% endif %} + {{ vital.measured_by.get_full_name }} + + + +
+ {% else %} +
+ +

No vital signs recorded for this encounter.

+ + Add Vital Signs + +
+ {% endif %}
- {% endfor %} + + + +
+
+
Clinical Notes
+ + Add Note + +
+ + {% if object.clinical_notes.exists %} +
+ + + + + + + + + + + + + + {% for note in clinical_notes %} + + + + + + + + + + {% endfor %} + +
Date/TimeTitleTypeAuthorStatusContent PreviewActions
{{ note.note_datetime|date:"M d, Y H:i" }}{{ note.title }}{{ note.get_note_type_display }}{{ note.author.get_full_name }} + + {{ note.get_status_display }} + + {% if note.electronically_signed %} + + {% endif %} + +
+ {{ note.content|truncatewords:15 }} +
+
+ + + + {% if note.status == 'DRAFT' and note.author == request.user %} + + + + {% endif %} +
+
+ {% else %} +
+ +

No clinical notes for this encounter.

+ + Add Clinical Note + +
+ {% endif %} +
+ + + +
+
+
Related Appointments
+ + Schedule Appointment + +
+ + {% if related_appointments %} +
+ + + + + + + + + + + + {% for appointment in related_appointments %} + + + + + + + + {% endfor %} + +
Date/TimeTypeProviderStatusActions
+ {% if appointment.scheduled_datetime %} + {{ appointment.scheduled_datetime|date:"M d, Y H:i" }} + {% else %} + {{ appointment.preferred_date|date:"M d, Y" }} + {% endif %} + {{ appointment.get_appointment_type_display }}{{ appointment.provider.get_full_name }} + + {{ appointment.get_status_display }} + + + + + +
+
+ {% else %} +
+ +

No related appointments found.

+ + Schedule Appointment + +
+ {% endif %} +
+ + + +
+
+
Laboratory Orders
+ + New Lab Order + +
+ + {% if lab_orders %} +
+ + + + + + + + + + + + + {% for order in lab_orders %} + + + + + + + + + {% endfor %} + +
Order #DateTestsStatusPriorityActions
{{ order.order_number }}{{ order.order_datetime|date:"M d, Y" }} + {{ order.tests.count }} test{{ order.tests.count|pluralize }} + + + {{ order.get_status_display }} + + + + {{ order.get_priority_display }} + + + + + +
+
+ {% else %} +
+ +

No laboratory orders found.

+ + Create Lab Order + +
+ {% endif %} +
+ + + +
+
+
Imaging Orders & Studies
+ + New Imaging Order + +
+ + {% if imaging_orders %} +
Imaging Orders
+
+ + + + + + + + + + + + + + {% for order in imaging_orders %} + + + + + + + + + + {% endfor %} + +
Order #DateStudyModalityStatusPriorityActions
{{ order.order_number }}{{ order.order_datetime|date:"M d, Y" }}{{ order.study_description|truncatechars:30 }} + {{ order.get_modality_display }} + + + {{ order.get_status_display }} + + + + {{ order.get_priority_display }} + + + + + +
+
+ {% endif %} + + {% if imaging_studies %} +
Imaging Studies
+
+ + + + + + + + + + + + + + {% for study in imaging_studies %} + + + + + + + + + + {% endfor %} + +
Accession #DateStudyModalityRadiologistStatusActions
{{ study.accession_number }}{{ study.study_date|date:"M d, Y" }}{{ study.study_description|truncatechars:30 }} + {{ study.get_modality_display }} + + {% if study.radiologist %} + {{ study.radiologist.get_full_name }} + {% else %} + Not assigned + {% endif %} + + + {{ study.get_status_display }} + + + + + +
+
+ {% endif %} + + {% if not imaging_orders and not imaging_studies %} +
+ +

No imaging orders or studies found.

+ + Create Imaging Order + +
+ {% endif %} +
+ + + +
+
+
Medical Bills
+ + Create Bill + +
+ + {% if medical_bills %} +
+ + + + + + + + + + + + + + {% for bill in medical_bills %} + + + + + + + + + + {% endfor %} + +
Bill #DateTypeTotal AmountBalanceStatusActions
{{ bill.bill_number }}{{ bill.bill_date|date:"M d, Y" }}{{ bill.get_bill_type_display }}${{ bill.total_amount|floatformat:2 }}${{ bill.balance_amount|floatformat:2 }} + + {{ bill.get_status_display }} + + + + + +
+
+ {% else %} +
+ +

No medical bills found.

+ + Create Bill + +
+ {% endif %} +
+
- {% else %} -
- -

No clinical notes for this encounter.

- - Add Clinical Note - -
- {% endif %} +
@@ -446,7 +787,7 @@ -
+
@@ -697,4 +1038,3 @@ function updateStatus(newStatus) { {# });#} {% endblock %} - diff --git a/emr/templates/emr/partials/emr_stats.html b/emr/templates/emr/partials/emr_stats.html index b7419b02..66e7c13b 100644 --- a/emr/templates/emr/partials/emr_stats.html +++ b/emr/templates/emr/partials/emr_stats.html @@ -1,68 +1,177 @@ -
-
-
-
-
-
Active Encounters
-

{{ stats.active_encounters }}

- In progress + +
+
+
+
+
+
+

{{ total_encounters }}

+

Total Encounters

+ {% if encounters_change %} + + + {{ encounters_change|floatformat:1 }}% from last month + + {% endif %} +
+
+ +
-
- +
+
+
+ +
+
+
+
+
+

{{ active_encounters }}

+

Active Encounters

+ {% if active_encounters > 0 %} + + + {{ avg_encounter_duration|floatformat:1 }}h avg duration + + {% endif %} +
+
+ +
+
+
+
+
+ +
+
+
+
+
+

{{ pending_documentation }}

+

Pending Documentation

+ {% if pending_documentation > 0 %} + + + Requires attention + + {% else %} + + + All up to date + + {% endif %} +
+
+ +
+
+
+
+
+ +
+
+
+
+
+

{{ critical_alerts }}

+

Critical Alerts

+ {% if critical_alerts > 0 %} + + + Immediate action needed + + {% else %} + + + No critical alerts + + {% endif %} +
+
+ +
-
-
-
-
-
-
Pending Documentation
-

{{ stats.pending_documentation }}

- Needs completion + +
+
+
+
+
+ Today's Activity +
+
+ New Encounters + {{ todays_encounters }}
-
- +
+ Completed + {{ completed_today }} +
+
+ Vital Signs Recorded + {{ vitals_today }} +
+
+
+
+ +
+
+
+
+ Provider Activity +
+
+ Active Providers + {{ active_providers }} +
+
+ Avg Encounters/Provider + {{ avg_encounters_per_provider|floatformat:1 }} +
+
+ Documentation Rate + + {{ documentation_rate|floatformat:1 }}% + +
+
+
+
+ +
+
+
+
+ Performance Metrics +
+
+ Avg Wait Time + + {{ avg_wait_time|floatformat:0 }}min + +
+
+ Encounter Efficiency + + {{ encounter_efficiency|floatformat:1 }}% + +
+
+ Quality Score + + {{ quality_score|floatformat:1 }}% +
- -
-
-
-
-
-
Unsigned Notes
-

{{ stats.unsigned_notes }}

- Awaiting signature -
-
- -
-
-
-
-
- -
-
-
-
-
-
Critical Vitals
-

{{ stats.critical_vitals }}

- Today -
-
- -
-
-
-
-
- diff --git a/emr/templates/emr/partials/vital_signs_chart.html b/emr/templates/emr/partials/vital_signs_chart.html index b03f37a9..321e347a 100644 --- a/emr/templates/emr/partials/vital_signs_chart.html +++ b/emr/templates/emr/partials/vital_signs_chart.html @@ -1,654 +1,296 @@ {% load static %} - -
-{#
#} -{#
Vital Signs Trends
#} -{#
#} -{# #} -{# #} -{# #} -{# #} -{#
#} -{#
#} - -{# #} - -{#
#} -{#
#} -{# #} -{# #} -{#
#} -{#
#} -{#
#} -{# #} -{#
#} -{#
#} -{#
#} -{#
#} -{# #} -{#
#} -{#
#} -{#
#} -{#
#} -{# #} -{#
#} -{#
#} -{#
#} -{#
#} -{# #} -{#
#} -{#
#} -{#
#} -{#
#} -{# #} -{#
#} -{#
#} -{#
#} -{#
#} -{# #} -{#
#} -{#
#} -{#
#} -{#
#} -{#
#} - -
-
-
- - - - - - - - - - - - - - - {% for vital in vital_signs %} - - - - - - - - - - - {% empty %} - - - - {% endfor %} - -
Date/TimeTemp (°C)BP (mmHg)HR (bpm)RR (bpm)O₂ Sat (%)Pain (0-10)Measured By
{{ vital.measured_datetime|date:"M d, Y H:i" }}{{ vital.temperature }}{{ vital.systolic_bp }}/{{ vital.diastolic_bp }}{{ vital.heart_rate }}{{ vital.respiratory_rate }}{{ vital.oxygen_saturation }}{{ vital.pain_scale }}{{ vital.measured_by.employee_profile.employee_number }}
No vital signs recorded
+ +
+
+
+
+ Vital Signs Trends +
+
+ + +
+
+ {% if vital_signs %} + +
+ + +
+ {% with latest=vital_signs.0 %} +
+
+
Temperature
+
+ {% if latest.temperature %} + {{ latest.temperature }}°C + {% else %} + -- + {% endif %} +
+ {% if latest.temperature_method %} + {{ latest.get_temperature_method_display }} + {% endif %} +
+
+
+
+
Blood Pressure
+
+ {% if latest.blood_pressure %} + {{ latest.blood_pressure }} + {% else %} + --/-- + {% endif %} +
+ {% if latest.bp_position %} + {{ latest.get_bp_position_display }} + {% endif %} +
+
+
+
+
Heart Rate
+
+ {% if latest.heart_rate %} + {{ latest.heart_rate }} bpm + {% else %} + -- + {% endif %} +
+ {% if latest.heart_rhythm %} + {{ latest.get_heart_rhythm_display }} + {% endif %} +
+
+
+
+
O2 Saturation
+
+ {% if latest.oxygen_saturation %} + {{ latest.oxygen_saturation }}% + {% else %} + --% + {% endif %} +
+ {% if latest.oxygen_delivery != 'ROOM_AIR' %} + {{ latest.get_oxygen_delivery_display }} + {% endif %} +
+
+ {% endwith %} +
+ + + {% for vital in vital_signs %} + {% if vital.has_critical_values %} + + {% endif %} + {% endfor %} + + +
+
Trends Analysis
+
+
+
+ Temperature Trend: + + {% if temp_trend == 'increasing' %} + Rising + {% elif temp_trend == 'decreasing' %} + Falling + {% else %} + Stable + {% endif %} + +
+
+ BP Trend: + + {% if bp_trend == 'increasing' %} + Rising + {% elif bp_trend == 'decreasing' %} + Falling + {% else %} + Stable + {% endif %} + +
+
+
+
+ Heart Rate Trend: + + {% if hr_trend == 'increasing' %} + Rising + {% elif hr_trend == 'decreasing' %} + Falling + {% else %} + Stable + {% endif %} + +
+
+ O2 Sat Trend: + + {% if o2_trend == 'increasing' %} + Improving + {% elif o2_trend == 'decreasing' %} + Declining + {% else %} + Stable + {% endif %} + +
+
+
+
+ + {% else %} +
+ +
No Vital Signs Data
+

No vital signs have been recorded yet.

+
+ {% endif %} +
- + + + \ No newline at end of file diff --git a/emr/templates/emr/problems/problem_form.html b/emr/templates/emr/problems/problem_form.html index 4c5079a0..c1463f14 100644 --- a/emr/templates/emr/problems/problem_form.html +++ b/emr/templates/emr/problems/problem_form.html @@ -57,16 +57,7 @@ {% endblock %} {% block content %} - - - +

@@ -96,6 +87,7 @@ +

@@ -120,65 +112,60 @@
Basic Information
-
- {{ form.patient }} - -
+ + {{ form.patient }} {% if form.patient.errors %}
{{ form.patient.errors }}
{% endif %}
-
-
- {{ form.problem_name }} - -
- {% if form.problem_name.errors %} -
{{ form.problem_name.errors }}
+
+ + {{ form.related_encounter }} + {% if form.related_encounter.errors %} +
{{ form.related_encounter.errors }}
+ {% endif %} +
+
+ +
+
+ + {{ form.icd10_search }} + + {% if form.icd10_search.help_text %} +
{{ form.icd10_search.help_text }}
{% endif %}
-
- {{ form.problem_type }} - -
+ + {{ form.problem_name }} + {% if form.problem_name.errors %} +
{{ form.problem_name.errors }}
+ {% endif %} +
+
+ + {{ form.problem_code }} + {% if form.problem_code.errors %} +
{{ form.problem_code.errors }}
+ {% endif %} +
+
+ +
+
+ + {{ form.problem_type }} {% if form.problem_type.errors %}
{{ form.problem_type.errors }}
{% endif %}
-
- {{ form.related_encounter }} - -
- {% if form.related_encounter.errors %} -
{{ form.related_encounter.errors }}
- {% endif %} -
-
-
- - -
-
Coding Information
-
-
-
- {{ form.problem_code }} - -
- {% if form.problem_code.errors %} -
{{ form.problem_code.errors }}
- {% endif %} -
-
-
- {{ form.coding_system }} - -
+ + {{ form.coding_system }} {% if form.coding_system.errors %}
{{ form.coding_system.errors }}
{% endif %} @@ -191,28 +178,22 @@
Clinical Information
-
- {{ form.severity }} - -
+ + {{ form.severity }} {% if form.severity.errors %}
{{ form.severity.errors }}
{% endif %}
-
- {{ form.priority }} - -
+ + {{ form.priority }} {% if form.priority.errors %}
{{ form.priority.errors }}
{% endif %}
-
- {{ form.status }} - -
+ + {{ form.status }} {% if form.status.errors %}
{{ form.status.errors }}
{% endif %} @@ -221,19 +202,15 @@
-
- {{ form.onset_date }} - -
+ + {{ form.onset_date }} {% if form.onset_date.errors %}
{{ form.onset_date.errors }}
{% endif %}
-
- {{ form.onset_description }} - -
+ + {{ form.onset_description }} {% if form.onset_description.errors %}
{{ form.onset_description.errors }}
{% endif %} @@ -242,19 +219,15 @@
-
- {{ form.body_site }} - -
+ + {{ form.body_site }} {% if form.body_site.errors %}
{{ form.body_site.errors }}
{% endif %}
-
- {{ form.laterality }} - -
+ + {{ form.laterality }} {% if form.laterality.errors %}
{{ form.laterality.errors }}
{% endif %} @@ -263,19 +236,15 @@
@@ -429,10 +428,10 @@
- + Use Template - + Duplicate diff --git a/emr/templates/emr/templates/note_template_form.html b/emr/templates/emr/templates/note_template_form.html index 56c71221..22ae7d76 100644 --- a/emr/templates/emr/templates/note_template_form.html +++ b/emr/templates/emr/templates/note_template_form.html @@ -133,14 +133,7 @@ {% endblock %} {% block content %} - - - +

{% if form.instance.id %}Edit{% else %}Create{% endif %} Note Template Standardized clinical documentation template

@@ -169,18 +162,18 @@
Basic Information
-
- - {{ form.title }} - {% if form.title.errors %} +
+ + {{ form.name }} + {% if form.name.errors %}
- {{ form.title.errors }} + {{ form.name.errors }}
{% endif %} -
Enter a descriptive title for this template.
+
Enter a descriptive name for this template.
-
+
{{ form.note_type }} {% if form.note_type.errors %} @@ -190,6 +183,16 @@ {% endif %}
Select the type of clinical note.
+
+ + {{ form.specialty }} + {% if form.specialty.errors %} +
+ {{ form.specialty.errors }} +
+ {% endif %} +
Select a specific specialty or leave blank for all specialties.
+
@@ -204,32 +207,23 @@
Provide a brief description of this template's purpose and usage.
-
-
- - {{ form.department }} - {% if form.department.errors %} +
+ + {{ form.template_content }} + {% if form.template_content.errors %}
- {{ form.department.errors }} + {{ form.template_content.errors }}
{% endif %} -
Select a specific department or leave blank for all departments.
-
+
Provide the content of this template.
+
+
+
+ -
- - {{ form.specialty }} - {% if form.specialty.errors %} -
- {{ form.specialty.errors }} -
- {% endif %} -
Select a specific specialty or leave blank for all specialties.
-
-
- -
+ +
{{ form.version }} @@ -241,16 +235,7 @@
Enter version number (e.g., 1.0, 2.1).
-
- - {{ form.status }} - {% if form.status.errors %} -
- {{ form.status.errors }} -
- {% endif %} -
Set the current status of this template.
-
+
diff --git a/emr/templates/emr/templates/note_template_list.html b/emr/templates/emr/templates/note_template_list.html index 278df0cb..7222b74c 100644 --- a/emr/templates/emr/templates/note_template_list.html +++ b/emr/templates/emr/templates/note_template_list.html @@ -69,13 +69,7 @@ {% endblock %} {% block content %} - - - +

Note Templates Standardized clinical documentation templates

@@ -232,7 +226,7 @@ @@ -307,7 +301,7 @@ - + diff --git a/emr/templates/emr/vital_signs/vital_signs_detail.html b/emr/templates/emr/vital_signs/vital_signs_detail.html index 46367ab1..b31e68d1 100644 --- a/emr/templates/emr/vital_signs/vital_signs_detail.html +++ b/emr/templates/emr/vital_signs/vital_signs_detail.html @@ -258,7 +258,7 @@ dl.row dd {

- {{ object.temperature }}°F + {{ object.temperature }}°C

{% if object.temperature_route %} Route: {{ object.get_temperature_route_display }} @@ -558,13 +558,20 @@ dl.row dd {
-
- +
Record New Vitals @@ -587,19 +594,13 @@ dl.row dd { {% endblock %} {% block js %} - + + {% endblock %} diff --git a/emr/urls.py b/emr/urls.py index eb1e94c6..8c61c736 100644 --- a/emr/urls.py +++ b/emr/urls.py @@ -24,6 +24,7 @@ urlpatterns = [ path('problems//', views.ProblemListDetailView.as_view(), name='problem_detail'), path('problems//update/', views.ProblemListUpdateView.as_view(), name='problem_update'), path('problems//delete/', views.ProblemListDeleteView.as_view(), name='problem_delete'), + path('problems/create/', views.ProblemListCreateView.as_view(), name='problem_create'), path('care-plans/', views.CarePlanListView.as_view(), name='care_plan_list'), path('care-plans//', views.CarePlanDetailView.as_view(), name='care_plan_detail'), @@ -38,7 +39,14 @@ urlpatterns = [ path('notes//update/', views.ClinicalNoteUpdateView.as_view(), name='clinical_note_update'), path('notes//delete/', views.ClinicalNoteDeleteView.as_view(), name='clinical_note_delete'), path('notes/create//', views.ClinicalNoteCreateView.as_view(), name='clinical_note_create'), - + + path('templates/', views.NoteTemplateListView.as_view(), name='note_template_list'), + path('templates//', views.NoteTemplateDetailView.as_view(), name='note_template_detail'), + path('templates//update/', views.NoteTemplateUpdateView.as_view(), name='note_template_update'), + path('templates//delete/', views.NoteTemplateDeleteView.as_view(), name='note_template_delete'), + path('templates/create/', views.NoteTemplateCreateView.as_view(), name='note_template_create'), + + # HTMX endpoints path('stats/', views.emr_stats, name='emr_stats'), path('encounter-search/', views.encounter_search, name='encounter_search'), @@ -57,7 +65,20 @@ urlpatterns = [ path("icd10/", views.Icd10SearchView.as_view(), name="icd10_search"), path("icd10//", views.Icd10DetailView.as_view(), name="icd10_detail"), + # Clinical Decision Support + path('clinical-decision-support/', views.clinical_decision_support, name='clinical_decision_support'), + path('api/patient-search/', views.patient_search_api, name='patient_search_api'), + path('api/clinical-recommendations/', views.get_clinical_recommendations, name='get_clinical_recommendations'), + path('api/drug-interactions/', views.check_drug_interactions, name='check_drug_interactions'), + path('api/allergies/', views.check_allergies, name='check_allergies'), + path('api/risk-scores/', views.calculate_risk_scores, name='calculate_risk_scores'), + path('api/accept-recommendation/', views.accept_recommendation, name='accept_recommendation'), + path('api/defer-recommendation/', views.defer_recommendation, name='defer_recommendation'), + path('api/dismiss-recommendation/', views.dismiss_recommendation, name='dismiss_recommendation'), + path('api/acknowledge-alert/', views.acknowledge_alert, name='acknowledge_alert'), + path('api/apply-protocol/', views.apply_protocol, name='apply_protocol'), + path('api/icd10-search/', views.icd10_search_api, name='icd10_search_api'), + # API endpoints # path('api/', include('emr.api.urls')), ] - diff --git a/emr/views.py b/emr/views.py index 39e6a2d3..a21dbce9 100644 --- a/emr/views.py +++ b/emr/views.py @@ -25,44 +25,34 @@ from core.mixins import TenantMixin, FormKwargsMixin from django.utils.dateformat import format as dj_format from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger from django.db.models import * +from pharmacy.models import * +from quality.models import * +from django.contrib.contenttypes.models import ContentType - - -class EMRDashboardView(LoginRequiredMixin, ListView): +class EMRDashboardView(LoginRequiredMixin, TemplateView): """ Main dashboard for EMR management. """ template_name = 'emr/dashboard.html' - context_object_name = 'encounters' - - def get_queryset(self): - """Get recent encounters for current tenant.""" - return Encounter.objects.filter( - tenant=self.request.user.tenant - ).select_related('patient', 'provider').order_by('-start_datetime')[:10] - + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) tenant = self.request.user.tenant today = timezone.now().date() - - # Dashboard statistics + + # Use model managers for optimized queries + encounters_manager = Encounter.objects + + # Dashboard statistics with optimized queries context.update({ - 'total_encounters': Encounter.objects.filter(tenant=tenant).count(), - 'active_encounters': Encounter.objects.filter( - tenant=tenant, - status__in=['ARRIVED', 'TRIAGED', 'IN_PROGRESS', 'ON_HOLD'] - ).count(), - 'todays_encounters': Encounter.objects.filter( - tenant=tenant, - start_datetime__date=today - ).count(), - 'pending_documentation': Encounter.objects.filter( - tenant=tenant, - documentation_complete=False, - status='FINISHED' - ).count(), + 'recent_encounters': encounters_manager.filter( + tenant=tenant + ).select_related('patient', 'provider').order_by('-start_datetime')[:10], + 'total_encounters': encounters_manager.filter(tenant=tenant).count(), + 'active_encounters': encounters_manager.active_encounters(tenant).count(), + 'todays_encounters': encounters_manager.todays_encounters(tenant).count(), + 'pending_documentation': encounters_manager.unsigned_encounters(tenant).count(), 'unsigned_notes': ClinicalNote.objects.filter( patient__tenant=tenant, status='COMPLETED', @@ -81,7 +71,7 @@ class EMRDashboardView(LoginRequiredMixin, ListView): measured_datetime__date=today ).exclude(critical_values=[]).count(), }) - + return context @@ -144,50 +134,132 @@ class EncounterListView(LoginRequiredMixin, ListView): class EncounterCreateView(LoginRequiredMixin, CreateView): + """ + Create view for encounters with proper validation and error handling. + """ model = Encounter form_class = EncounterForm template_name = 'emr/encounters/encounter_create.html' success_message = _('Encounter for %(patient)s created successfully.') + def dispatch(self, request, *args, **kwargs): + """Check permissions before allowing access.""" + if not request.user.has_perm('emr.add_encounter'): + messages.error(request, _('You do not have permission to create encounters.')) + return redirect('emr:encounter_list') + return super().dispatch(request, *args, **kwargs) + + def get_form_kwargs(self): + """Pass additional context to form.""" + kwargs = super().get_form_kwargs() + kwargs['tenant'] = self.request.user.tenant + kwargs['user'] = self.request.user + return kwargs + def form_valid(self, form): - form.instance.tenant = self.request.user.tenant - response = super().form_valid(form) - AuditLogEntry.objects.create( - tenant=self.request.user.tenant, - user=self.request.user, - action='CREATE', - model_name='Encounter', - object_id=str(self.object.pk), - changes={'status': 'Encounter created'} - ) - return response + """Handle successful form submission.""" + try: + form.instance.tenant = self.request.user.tenant + form.instance.created_by = self.request.user + + # Validate business rules + if form.instance.end_datetime and form.instance.end_datetime <= form.instance.start_datetime: + messages.error(self.request, _('End date/time must be after start date/time.')) + return self.form_invalid(form) + + response = super().form_valid(form) + + # Log successful creation + try: + from django.contrib.contenttypes.models import ContentType + AuditLogEntry.objects.create( + tenant=self.request.user.tenant, + user=self.request.user, + event_type='CREATE', + event_category='CLINICAL_DATA', + action='Create Encounter', + description=f'Encounter created for {self.object.patient.get_full_name()}', + content_type=ContentType.objects.get_for_model(Encounter), + object_id=self.object.pk, + object_repr=str(self.object), + patient_id=str(self.object.patient.patient_id), + patient_mrn=self.object.patient.mrn, + changes={'encounter_type': self.object.encounter_type, 'status': self.object.status} + ) + except Exception as e: + # Log audit failure but don't block user flow + print(f"Audit logging failed: {e}") + + messages.success(self.request, self.success_message % {'patient': self.object.patient.get_full_name()}) + return response + + except Exception as e: + messages.error(self.request, _('An error occurred while creating the encounter. Please try again.')) + return self.form_invalid(form) + + def form_invalid(self, form): + """Handle form validation errors.""" + for field, errors in form.errors.items(): + for error in errors: + messages.error(self.request, f"{field.title()}: {error}") + return super().form_invalid(form) def get_success_url(self): return reverse_lazy('emr:encounter_detail', kwargs={'pk': self.object.pk}) + def get_context_data(self, **kwargs): + """Add additional context for template rendering.""" + context = super().get_context_data(**kwargs) + context['patient_id'] = self.request.GET.get('patient') + return context + class EncounterDetailView(LoginRequiredMixin, DetailView): """ - Detail view for encounter. + Detail view for encounter with optimized queries. """ model = Encounter template_name = 'emr/encounters/encounter_detail.html' context_object_name = 'encounter' - + def get_queryset(self): - return Encounter.objects.filter(tenant=self.request.user.tenant) - + tenant = self.request.user.tenant + return Encounter.objects.filter(tenant=tenant).select_related( + 'patient', 'provider', 'appointment', 'admission', + ).prefetch_related( + 'vital_signs__measured_by', + 'clinical_notes__author', + 'problems_identified__diagnosing_provider' + ) + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) encounter = self.object - - # Get related data + + # Use model properties and optimized queries context.update({ 'vital_signs': encounter.vital_signs.all().order_by('-measured_datetime'), 'clinical_notes': encounter.clinical_notes.all().order_by('-note_datetime'), 'problems': encounter.problems_identified.all().order_by('-created_at'), + # 'can_edit': encounter.can_be_edited, + 'duration_display': encounter.duration, + 'status_color': encounter.get_status_color(), }) + + # Add related appointments + context['related_appointments'] = encounter.patient.appointment_requests.all().select_related('provider').order_by('-scheduled_datetime')[:10] + + # Add related laboratory orders and results + context['lab_orders'] = encounter.patient.lab_orders.all().select_related('ordering_provider').prefetch_related('tests', 'results__test').order_by('-order_datetime')[:10] + + # Add related imaging orders and studies + context['imaging_orders'] = encounter.patient.imaging_orders.all().select_related('ordering_provider').order_by('-order_datetime')[:10] + context['imaging_studies'] = encounter.patient.imaging_studies.all().select_related('referring_physician', 'radiologist').order_by('-study_datetime')[:10] + + # Add related billing information + context['medical_bills'] = encounter.patient.medical_bills.all().select_related('primary_insurance', 'secondary_insurance').order_by('-bill_date')[:10] + return context @@ -201,7 +273,8 @@ class VitalSignsListView(LoginRequiredMixin, ListView): paginate_by = 25 def get_queryset(self): - queryset = VitalSigns.objects.filter(patient__tenant=self.request.user.tenant) + tenant = self.request.user.tenant + queryset = VitalSigns.objects.filter(patient__tenant=tenant) # Filter by patient patient_id = self.request.GET.get('patient') @@ -235,36 +308,37 @@ class VitalSignsListView(LoginRequiredMixin, ListView): class ProblemListView(LoginRequiredMixin, ListView): """ - List view for problem list. + List view for problem list with optimized queries. """ model = ProblemList template_name = 'emr/problems/problem_list.html' context_object_name = 'problems' paginate_by = 25 - + def get_queryset(self): - queryset = ProblemList.objects.filter(tenant=self.request.user.tenant) - + tenant = self.request.user.tenant + queryset = ProblemList.objects.filter(tenant=tenant) + # Filter by patient patient_id = self.request.GET.get('patient') if patient_id: queryset = queryset.filter(patient_id=patient_id) - + # Filter by status status = self.request.GET.get('status') if status: queryset = queryset.filter(status=status) - + # Filter by problem type problem_type = self.request.GET.get('problem_type') if problem_type: queryset = queryset.filter(problem_type=problem_type) - + # Filter by priority priority = self.request.GET.get('priority') if priority: queryset = queryset.filter(priority=priority) - + # Search functionality search = self.request.GET.get('search') if search: @@ -275,53 +349,61 @@ class ProblemListView(LoginRequiredMixin, ListView): Q(problem_name__icontains=search) | Q(problem_code__icontains=search) ) - + return queryset.select_related( 'patient', 'diagnosing_provider', 'managing_provider' + ).prefetch_related( + 'care_plans__primary_provider' ).order_by('-created_at') - + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) + + # Use model properties for choices context.update({ 'problem_types': ProblemList._meta.get_field('problem_type').choices, 'problem_statuses': ProblemList._meta.get_field('status').choices, 'priorities': ProblemList._meta.get_field('priority').choices, + 'active_problems_count': sum(1 for p in context['problems'] if p.is_active), + 'chronic_problems_count': sum(1 for p in context['problems'] if p.is_chronic), }) + return context class CarePlanListView(LoginRequiredMixin, ListView): """ - List view for care plans. + List view for care plans with optimized queries. """ model = CarePlan template_name = 'emr/care_plans/care_plan_list.html' context_object_name = 'care_plans' paginate_by = 25 - + def get_queryset(self): - queryset = CarePlan.objects.filter(tenant=self.request.user.tenant) - + tenant = self.request.user.tenant + queryset = CarePlan.objects.filter(tenant=tenant) + # Filter by patient patient_id = self.request.GET.get('patient') if patient_id: queryset = queryset.filter(patient_id=patient_id) - + # Filter by status status = self.request.GET.get('status') if status: queryset = queryset.filter(status=status) - + # Filter by plan type plan_type = self.request.GET.get('plan_type') if plan_type: queryset = queryset.filter(plan_type=plan_type) - + # Filter by provider provider_id = self.request.GET.get('provider') if provider_id: queryset = queryset.filter(primary_provider_id=provider_id) - + # Search functionality search = self.request.GET.get('search') if search: @@ -332,15 +414,23 @@ class CarePlanListView(LoginRequiredMixin, ListView): Q(title__icontains=search) | Q(description__icontains=search) ) - - return queryset.select_related('patient', 'primary_provider').order_by('-created_at') - + + return queryset.select_related('patient', 'primary_provider').prefetch_related( + 'care_team', 'related_problems' + ).order_by('-created_at') + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) + + # Use model properties for choices and statistics context.update({ 'plan_types': CarePlan._meta.get_field('plan_type').choices, 'plan_statuses': CarePlan._meta.get_field('status').choices, + 'active_plans_count': sum(1 for cp in context['care_plans'] if cp.is_active), + 'overdue_plans_count': sum(1 for cp in context['care_plans'] if cp.is_overdue), + 'completed_plans_count': sum(1 for cp in context['care_plans'] if cp.status == 'COMPLETED'), }) + return context @@ -354,7 +444,8 @@ class ClinicalNoteListView(LoginRequiredMixin, ListView): paginate_by = 25 def get_queryset(self): - queryset = ClinicalNote.objects.filter(patient__tenant=self.request.user.tenant) + tenant = self.request.user.tenant + queryset = ClinicalNote.objects.filter(patient__tenant=tenant) # Filter by patient patient_id = self.request.GET.get('patient') @@ -413,13 +504,20 @@ class ClinicalNoteCreateView(LoginRequiredMixin, CreateView): def form_valid(self, form): form.instance.author = self.request.user response = super().form_valid(form) + from django.contrib.contenttypes.models import ContentType AuditLogEntry.objects.create( tenant=self.request.user.tenant, user=self.request.user, - action='CREATE', - model_name='ClinicalNote', - object_id=str(self.object.pk), - changes={'status': 'Clinical note created'} + event_type='CREATE', + event_category='CLINICAL_DATA', + action='Create Clinical Note', + description=f'Clinical note created: {self.object.title}', + content_type=ContentType.objects.get_for_model(ClinicalNote), + object_id=self.object.pk, + object_repr=str(self.object), + patient_id=str(self.object.patient.patient_id), + patient_mrn=self.object.patient.mrn, + changes={'note_type': self.object.note_type, 'status': self.object.status} ) return response @@ -444,14 +542,20 @@ class ClinicalNoteUpdateView(LoginRequiredMixin,UpdateView): def form_valid(self, form): response = super().form_valid(form) - + from django.contrib.contenttypes.models import ContentType AuditLogEntry.objects.create( tenant=self.request.user.tenant, user=self.request.user, - action='UPDATE', - model_name='ClinicalNote', - object_id=str(self.object.pk), - changes={'status': 'Clinical note updated'} + event_type='UPDATE', + event_category='CLINICAL_DATA', + action='Update Clinical Note', + description=f'Clinical note updated: {self.object.title}', + content_type=ContentType.objects.get_for_model(ClinicalNote), + object_id=self.object.pk, + object_repr=str(self.object), + patient_id=str(self.object.patient.patient_id), + patient_mrn=self.object.patient.mrn, + changes={'status': self.object.status} ) return response @@ -476,264 +580,26 @@ class ClinicalNoteDeleteView(LoginRequiredMixin,SuccessMessageMixin, DeleteView) def delete(self, request, *args, **kwargs): cn = self.get_object() + from django.contrib.contenttypes.models import ContentType AuditLogEntry.objects.create( tenant=request.user.tenant, user=request.user, - action='DELETE', - model_name='ClinicalNote', - object_id=str(cn.pk), + event_type='DELETE', + event_category='CLINICAL_DATA', + action='Delete Clinical Note', + description=f'Clinical note deleted: {cn.title}', + content_type=ContentType.objects.get_for_model(ClinicalNote), + object_id=cn.pk, + object_repr=str(cn), + patient_id=str(cn.patient.patient_id), + patient_mrn=cn.patient.mrn, changes={'status': 'Clinical note deleted'} ) messages.success(request, self.success_message) return super().delete(request, *args, **kwargs) -# -# # encounters/views.py -# -# from django.shortcuts import get_object_or_404, redirect, render -# from django.urls import reverse_lazy -# from django.contrib.auth.decorators import login_required -# from django.contrib.auth.mixins import LoginRequiredMixin -# from django.contrib import messages -# from django.views.generic import ( -# TemplateView, ListView, DetailView, CreateView, UpdateView, DeleteView -# ) -# from django.views.generic.edit import FormMixin -# from django.contrib.messages.views import SuccessMessageMixin -# from django.db.models import Q, Avg -# from django.utils import timezone -# from django.http import JsonResponse -# -# from .models import * -# from .forms import * -# from core.models import AuditLogEntry -# from patients.models import PatientProfile -# from django.utils.translation import gettext_lazy as _ -# -# -# # Mixins ------------------------------------------------------------------------- -# -# class TenantMixin: -# def get_queryset(self): -# qs = super().get_queryset() -# tenant = getattr(self.request.user, 'tenant', None) -# if tenant and not self.request.user.is_superuser: -# # Models with patient FK: -# if hasattr(qs.model, 'patient'): -# return qs.filter(patient__tenant=tenant) -# # NoteTemplate uses tenant directly: -# return qs.filter(tenant=tenant) -# return qs -# -# def get_object(self, queryset=None): -# qs = queryset or self.get_queryset() -# return super().get_object(qs) -# -# -# class FormKwargsMixin: -# def get_form_kwargs(self): -# kw = super().get_form_kwargs() -# kw['user'] = self.request.user -# kw['tenant'] = getattr(self.request.user, 'tenant', None) -# return kw -# -# -# # Dashboard ---------------------------------------------------------------------- -# -# class DashboardView(LoginRequiredMixin, TemplateView): -# template_name = 'emr/dashboard.html' -# -# def get_context_data(self, **kwargs): -# ctx = super().get_context_data(**kwargs) -# tenant = getattr(self.request.user, 'tenant', None) -# today = timezone.now().date() -# week_ago = today - timezone.timedelta(days=7) -# -# enc = Encounter.objects.filter(patient__tenant=tenant) -# vs = VitalSigns.objects.filter(encounter__patient__tenant=tenant) -# pb = ProblemList.objects.filter(patient__tenant=tenant) -# cp = CarePlan.objects.filter(patient__tenant=tenant) -# cn = ClinicalNote.objects.filter(encounter__patient__tenant=tenant) -# -# ctx.update({ -# 'total_encounters': enc.count(), -# 'encounters_today': enc.filter(scheduled_datetime__date=today).count(), -# 'encounters_this_week': enc.filter(scheduled_datetime__date__gte=week_ago).count(), -# 'active_encounters': enc.filter(status='IN_PROGRESS').count(), -# -# 'total_vital_signs': vs.count(), -# 'vital_signs_today': vs.filter(recorded_at__date=today).count(), -# 'avg_temp_week': vs.filter(recorded_at__date__gte=week_ago).aggregate(Avg('temperature'))['temperature__avg'], -# 'avg_hr_week': vs.filter(recorded_at__date__gte=week_ago).aggregate(Avg('heart_rate'))['heart_rate__avg'], -# -# 'total_problems': pb.count(), -# 'active_problems': pb.filter(status='ACTIVE').count(), -# 'resolved_problems': pb.filter(status='RESOLVED').count(), -# -# 'total_care_plans': cp.count(), -# 'active_care_plans': cp.filter(status='ACTIVE').count(), -# 'completed_care_plans': cp.filter(status='COMPLETED').count(), -# -# 'total_notes': cn.count(), -# 'notes_today': cn.filter(created_at__date=today).count(), -# -# 'recent_encounters': enc.select_related('patient','provider').order_by('-scheduled_datetime')[:5], -# 'recent_vitals': vs.select_related('encounter','recorded_by').order_by('-recorded_at')[:5], -# 'recent_notes': cn.select_related('encounter','author').order_by('-created_at')[:5], -# }) -# return ctx -# -# -# # Encounter ---------------------------------------------------------------------- -# -# class EncounterListView(LoginRequiredMixin, TenantMixin, FormMixin, ListView): -# model = Encounter -# template_name = 'emr/encounter_list.html' -# context_object_name = 'encounters' -# paginate_by = 20 -# form_class = EMRSearchForm -# -# def get_queryset(self): -# qs = super().get_queryset().select_related('patient','provider').order_by('-scheduled_datetime') -# if self.request.GET: -# form = self.get_form() -# if form.is_valid(): -# cd = form.cleaned_data -# if cd.get('search'): -# qs = qs.filter( -# Q(patient__first_name__icontains=cd['search']) | -# Q(patient__last_name__icontains=cd['search']) | -# Q(chief_complaint__icontains=cd['search']) -# ) -# for fld in ('patient','provider','encounter_type','status'): -# if cd.get(fld): -# qs = qs.filter(**{fld: cd[fld]}) -# if cd.get('date_from'): -# qs = qs.filter(scheduled_datetime__date__gte=cd['date_from']) -# if cd.get('date_to'): -# qs = qs.filter(scheduled_datetime__date__lte=cd['date_to']) -# return qs -# -# def get_context_data(self, **kwargs): -# ctx = super().get_context_data(**kwargs) -# ctx['search_form'] = self.get_form() -# ctx['total_count'] = self.get_queryset().count() -# return ctx -# -# -# class EncounterDetailView(LoginRequiredMixin, TenantMixin, DetailView): -# model = Encounter -# template_name = 'emr/encounter_detail.html' -# context_object_name = 'encounter' -# -# def get_context_data(self, **kwargs): -# ctx = super().get_context_data(**kwargs) -# enc = self.object -# ctx.update({ -# 'vital_signs': VitalSigns.objects.filter(encounter=enc).order_by('-recorded_at'), -# 'clinical_notes': ClinicalNote.objects.filter(encounter=enc).order_by('-created_at'), -# 'problems': ProblemList.objects.filter(patient=enc.patient, status='ACTIVE'), -# 'care_plans': CarePlan.objects.filter(patient=enc.patient, status='ACTIVE'), -# }) -# return ctx -# -# -# class EncounterCreateView( -# LoginRequiredMixin, FormKwargsMixin, -# SuccessMessageMixin, CreateView -# ): -# model = Encounter -# form_class = EncounterForm -# template_name = 'emr/encounter_form.html' -# success_message = _('Encounter for %(patient)s created successfully.') -# -# def form_valid(self, form): -# form.instance.tenant = self.request.user.tenant -# response = super().form_valid(form) -# AuditLogEntry.objects.create( -# tenant=self.request.user.tenant, -# user=self.request.user, -# action='CREATE', -# model_name='Encounter', -# object_id=str(self.object.pk), -# changes={'status': 'Encounter created'} -# ) -# return response -# -# def get_success_url(self): -# return reverse_lazy('emr:encounter_detail', kwargs={'pk': self.object.pk}) -# -# -# class EncounterUpdateView( -# LoginRequiredMixin, FormKwargsMixin, -# SuccessMessageMixin, TenantMixin, UpdateView -# ): -# model = Encounter -# form_class = EncounterForm -# template_name = 'emr/encounter_form.html' -# success_message = _('Encounter for %(patient)s updated successfully.') -# -# def dispatch(self, request, *args, **kwargs): -# enc = self.get_object() -# if enc.status == 'COMPLETED' and not request.user.is_superuser: -# messages.error(request, _('Cannot modify a completed encounter.')) -# return redirect('emr:encounter_detail', pk=enc.pk) -# return super().dispatch(request, *args, **kwargs) -# -# def form_valid(self, form): -# response = super().form_valid(form) -# AuditLogEntry.objects.create( -# tenant=self.request.user.tenant, -# user=self.request.user, -# action='UPDATE', -# model_name='Encounter', -# object_id=str(self.object.pk), -# changes={'status': 'Encounter updated'} -# ) -# return response -# -# def get_success_url(self): -# return reverse_lazy('emr:encounter_detail', kwargs={'pk': self.object.pk}) -# -# -# class EncounterDeleteView( -# LoginRequiredMixin, TenantMixin, -# SuccessMessageMixin, DeleteView -# ): -# model = Encounter -# template_name = 'emr/encounter_confirm_delete.html' -# success_url = reverse_lazy('emr:encounter_list') -# success_message = _('Encounter deleted successfully.') -# -# def delete(self, request, *args, **kwargs): -# enc = self.get_object() -# AuditLogEntry.objects.create( -# tenant=request.user.tenant, -# user=request.user, -# action='DELETE', -# model_name='Encounter', -# object_id=str(enc.pk), -# changes={'status': 'Encounter deleted'} -# ) -# messages.success(request, self.success_message) -# return super().delete(request, *args, **kwargs) -# -# -# # VitalSigns ---------------------------------------------------------------------- -# -# class VitalSignsListView(LoginRequiredMixin, TenantMixin, ListView): -# model = VitalSigns -# template_name = 'emr/vital_signs_list.html' -# context_object_name = 'vital_signs' -# paginate_by = 20 -# -# def get_queryset(self): -# qs = super().get_queryset().select_related('encounter','recorded_by').order_by('-recorded_at') -# # (Search/filter logic would use a VitalSignsSearchForm — omitted for brevity) -# return qs -# -class VitalSignsDetailView(LoginRequiredMixin, DetailView): +class VitalSignsDetailView(LoginRequiredMixin, TenantMixin, DetailView): model = VitalSigns template_name = 'emr/vital_signs/vital_signs_detail.html' context_object_name = 'vital_signs' @@ -747,8 +613,8 @@ class VitalSignsCreateView(LoginRequiredMixin, CreateView): def dispatch(self, request, *args, **kwargs): # Ensure tenant exists - self.tenant = getattr(request, "tenant", None) - if not self.tenant: + tenant = self.request.user.tenant + if not tenant: return JsonResponse({"error": "No tenant found"}, status=400) return super().dispatch(request, *args, **kwargs) @@ -756,7 +622,7 @@ class VitalSignsCreateView(LoginRequiredMixin, CreateView): def get_form_kwargs(self): kwargs = super().get_form_kwargs() # pass what the form actually expects - kwargs['tenant'] = self.tenant + # kwargs['tenant'] = self.request.user.tenant kwargs['user'] = self.request.user # (Optional) initial values for fields that ARE in the form: # kwargs.setdefault('initial', {}) @@ -792,9 +658,10 @@ class VitalSignsCreateView(LoginRequiredMixin, CreateView): def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) + tenant = self.request.user.tenant # encounter is coming from URL; fetch it with tenant check encounter = get_object_or_404( - Encounter, pk=self.kwargs['pk'], tenant=self.tenant + Encounter, pk=self.kwargs['pk'], tenant=tenant ) ctx['patient'] = encounter.patient ctx['encounter'] = encounter @@ -826,14 +693,22 @@ class ProblemListCreateView(LoginRequiredMixin, CreateView): def form_valid(self, form): form.instance.identified_by = self.request.user + form.instance.tenant = self.request.user.tenant response = super().form_valid(form) + from django.contrib.contenttypes.models import ContentType AuditLogEntry.objects.create( tenant=self.request.user.tenant, user=self.request.user, - action='CREATE', - model_name='ProblemList', - object_id=str(self.object.pk), - changes={'status': 'Problem added'} + event_type='CREATE', + event_category='CLINICAL_DATA', + action='Create Problem', + description=f'Problem added: {self.object.problem_name}', + content_type=ContentType.objects.get_for_model(ProblemList), + object_id=self.object.pk, + object_repr=str(self.object), + patient_id=str(self.object.patient.patient_id), + patient_mrn=self.object.patient.mrn, + changes={'problem_type': self.object.problem_type, 'status': self.object.status} ) return response @@ -849,13 +724,20 @@ class ProblemListUpdateView(LoginRequiredMixin, UpdateView): def form_valid(self, form): response = super().form_valid(form) + from django.contrib.contenttypes.models import ContentType AuditLogEntry.objects.create( tenant=self.request.user.tenant, user=self.request.user, - action='UPDATE', - model_name='ProblemList', - object_id=str(self.object.pk), - changes={'status': 'Problem updated'} + event_type='UPDATE', + event_category='CLINICAL_DATA', + action='Update Problem', + description=f'Problem updated: {self.object.problem_name}', + content_type=ContentType.objects.get_for_model(ProblemList), + object_id=self.object.pk, + object_repr=str(self.object), + patient_id=str(self.object.patient.patient_id), + patient_mrn=self.object.patient.mrn, + changes={'status': self.object.status} ) return response @@ -944,11 +826,11 @@ class CarePlanProgressUpdateView(LoginRequiredMixin, UpdateView): def get_form_kwargs(self): kwargs = super().get_form_kwargs() - kwargs['tenant'] = getattr(self.request, 'tenant', None) + kwargs['tenant'] = self.request.user.tenant return kwargs def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) + tenant = self.request.user.tenant if not tenant: return CarePlan.objects.none() # Limit by tenant, and optionally eager-load relations used in your template @@ -1005,231 +887,144 @@ class CarePlanDeleteView(LoginRequiredMixin, DeleteView): ) messages.success(request, self.success_message) return super().delete(request, *args, **kwargs) -# -# -# # ClinicalNote -------------------------------------------------------------------- -# -# class ClinicalNoteListView(LoginRequiredMixin, TenantMixin, ListView): -# model = ClinicalNote -# template_name = 'emr/clinical_note_list.html' -# context_object_name = 'notes' -# paginate_by = 20 -# -# -# class ClinicalNoteDetailView(LoginRequiredMixin, TenantMixin, DetailView): -# model = ClinicalNote -# template_name = 'emr/clinical_note_detail.html' -# context_object_name = 'note' -# -# -# class ClinicalNoteCreateView( -# LoginRequiredMixin, FormKwargsMixin, -# SuccessMessageMixin, CreateView -# ): -# model = ClinicalNote -# form_class = ClinicalNoteForm -# template_name = 'emr/clinical_note_form.html' -# success_message = _('Clinical note created successfully.') -# -# def form_valid(self, form): -# form.instance.author = self.request.user -# response = super().form_valid(form) -# AuditLogEntry.objects.create( -# tenant=self.request.user.tenant, -# user=self.request.user, -# action='CREATE', -# model_name='ClinicalNote', -# object_id=str(self.object.pk), -# changes={'status': 'Clinical note created'} -# ) -# return response -# -# def get_success_url(self): -# return reverse_lazy('emr:clinical_note_detail', kwargs={'pk': self.object.pk}) -# -# -# class ClinicalNoteUpdateView( -# LoginRequiredMixin, FormKwargsMixin, -# SuccessMessageMixin, TenantMixin, UpdateView -# ): -# model = ClinicalNote -# form_class = ClinicalNoteForm -# template_name = 'emr/clinical_note_form.html' -# success_message = _('Clinical note updated successfully.') -# -# def form_valid(self, form): -# response = super().form_valid(form) -# AuditLogEntry.objects.create( -# tenant=self.request.user.tenant, -# user=self.request.user, -# action='UPDATE', -# model_name='ClinicalNote', -# object_id=str(self.object.pk), -# changes={'status': 'Clinical note updated'} -# ) -# return response -# -# def get_success_url(self): -# return reverse_lazy('emr:clinical_note_detail', kwargs={'pk': self.object.pk}) -# -# -# class ClinicalNoteDeleteView( -# LoginRequiredMixin, TenantMixin, -# SuccessMessageMixin, DeleteView -# ): -# model = ClinicalNote -# template_name = 'emr/clinical_note_confirm_delete.html' -# success_url = reverse_lazy('emr:clinical_note_list') -# success_message = _('Clinical note deleted successfully.') -# -# def delete(self, request, *args, **kwargs): -# cn = self.get_object() -# AuditLogEntry.objects.create( -# tenant=request.user.tenant, -# user=request.user, -# action='DELETE', -# model_name='ClinicalNote', -# object_id=str(cn.pk), -# changes={'status': 'Clinical note deleted'} -# ) -# messages.success(request, self.success_message) -# return super().delete(request, *args, **kwargs) -# -# -# # NoteTemplate ------------------------------------------------------------------- -# -# class NoteTemplateListView(LoginRequiredMixin, TenantMixin, ListView): -# model = NoteTemplate -# template_name = 'emr/note_template_list.html' -# context_object_name = 'templates' -# paginate_by = 20 -# -# -# class NoteTemplateDetailView(LoginRequiredMixin, TenantMixin, DetailView): -# model = NoteTemplate -# template_name = 'emr/note_template_detail.html' -# context_object_name = 'template' -# -# -# class NoteTemplateCreateView( -# LoginRequiredMixin, FormKwargsMixin, -# SuccessMessageMixin, CreateView -# ): -# model = NoteTemplate -# form_class = NoteTemplateForm -# template_name = 'emr/note_template_form.html' -# success_message = _('Note template created successfully.') -# -# def form_valid(self, form): -# form.instance.tenant = self.request.user.tenant -# form.instance.created_by = self.request.user -# response = super().form_valid(form) -# AuditLogEntry.objects.create( -# tenant=self.request.user.tenant, -# user=self.request.user, -# action='CREATE', -# model_name='NoteTemplate', -# object_id=str(self.object.pk), -# changes={'status': 'Template created'} -# ) -# return response -# -# def get_success_url(self): -# return reverse_lazy('emr:note_template_detail', kwargs={'pk': self.object.pk}) -# -# -# class NoteTemplateUpdateView( -# LoginRequiredMixin, FormKwargsMixin, -# SuccessMessageMixin, TenantMixin, UpdateView -# ): -# model = NoteTemplate -# form_class = NoteTemplateForm -# template_name = 'emr/note_template_form.html' -# success_message = _('Note template updated successfully.') -# -# def form_valid(self, form): -# response = super().form_valid(form) -# AuditLogEntry.objects.create( -# tenant=self.request.user.tenant, -# user=self.request.user, -# action='UPDATE', -# model_name='NoteTemplate', -# object_id=str(self.object.pk), -# changes={'status': 'Template updated'} -# ) -# return response -# -# def get_success_url(self): -# return reverse_lazy('emr:note_template_detail', kwargs={'pk': self.object.pk}) -# -# -# class NoteTemplateDeleteView( -# LoginRequiredMixin, TenantMixin, -# SuccessMessageMixin, DeleteView -# ): -# model = NoteTemplate -# template_name = 'emr/note_template_confirm_delete.html' -# success_url = reverse_lazy('emr:note_template_list') -# success_message = _('Note template deleted successfully.') -# -# def delete(self, request, *args, **kwargs): -# nt = self.get_object() -# if ClinicalNote.objects.filter(template=nt).exists(): -# messages.error(request, _('Cannot delete a template in use.')) -# return redirect('emr:note_template_detail', pk=nt.pk) -# -# AuditLogEntry.objects.create( -# tenant=request.user.tenant, -# user=request.user, -# action='DELETE', -# model_name='NoteTemplate', -# object_id=str(nt.pk), -# changes={'status': 'Template deleted'} -# ) -# messages.success(request, self.success_message) -# return super().delete(request, *args, **kwargs) -# -# -# # HTMX & Actions ------------------------------------------------------------------ -# -# @login_required -# def htmx_emr_stats(request): -# tenant = request.user.tenant -# today = timezone.now().date() -# enc = Encounter.objects.filter(patient__tenant=tenant) -# vs = VitalSigns.objects.filter(encounter__patient__tenant=tenant) -# pb = ProblemList.objects.filter(patient__tenant=tenant) -# cp = CarePlan.objects.filter(patient__tenant=tenant) -# cn = ClinicalNote.objects.filter(encounter__patient__tenant=tenant) -# -# stats = { -# 'total_encounters': enc.count(), -# 'encounters_today': enc.filter(scheduled_datetime__date=today).count(), -# 'active_encounters': enc.filter(status='IN_PROGRESS').count(), -# 'total_vital_signs': vs.count(), -# 'vital_signs_today': vs.filter(recorded_at__date=today).count(), -# 'active_problems': pb.filter(status='ACTIVE').count(), -# 'active_care_plans': cp.filter(status='ACTIVE').count(), -# 'total_notes': cn.count(), -# 'notes_today': cn.filter(created_at__date=today).count(), -# } -# return JsonResponse(stats) -# + + +class NoteTemplateListView(LoginRequiredMixin, TenantMixin, ListView): + model = NoteTemplate + template_name = 'emr/templates/note_template_list.html' + context_object_name = 'templates' + paginate_by = 20 + + +class NoteTemplateDetailView(LoginRequiredMixin, TenantMixin, DetailView): + model = NoteTemplate + template_name = 'emr/templates/note_template_detail.html' + context_object_name = 'template' + + def context_data(self, **kwargs): + tenant = self.request.user.tenant + ctx = NoteTemplate.objects.filter(tenant=tenant).order_by('-created_at') + + return ctx + + +class NoteTemplateCreateView(LoginRequiredMixin, CreateView): + model = NoteTemplate + form_class = NoteTemplateForm + template_name = 'emr/templates/note_template_form.html' + success_message = _('Note template created successfully.') + + def form_valid(self, form): + form.instance.tenant = self.request.user.tenant + form.instance.created_by = self.request.user + response = super().form_valid(form) + AuditLogEntry.objects.create( + tenant=self.request.user.tenant, + user=self.request.user, + action='CREATE', + model_name='NoteTemplate', + object_id=str(self.object.pk), + changes={'status': 'Template created'} + ) + return response + + def get_success_url(self): + return reverse_lazy('emr:note_template_detail', kwargs={'pk': self.object.pk}) + + +class NoteTemplateUpdateView(LoginRequiredMixin, UpdateView): + model = NoteTemplate + form_class = NoteTemplateForm + template_name = 'emr/templates/note_template_form.html' + success_message = _('Note template updated successfully.') + + def form_valid(self, form): + response = super().form_valid(form) + AuditLogEntry.objects.create( + tenant=self.request.user.tenant, + user=self.request.user, + action='UPDATE', + model_name='NoteTemplate', + object_id=str(self.object.pk), + changes={'status': 'Template updated'} + ) + return response + + def get_success_url(self): + return reverse_lazy('emr:note_template_detail', kwargs={'pk': self.object.pk}) + + +class NoteTemplateDeleteView(LoginRequiredMixin, DeleteView): + model = NoteTemplate + template_name = 'emr/note_template_confirm_delete.html' + success_url = reverse_lazy('emr:note_template_list') + success_message = _('Note template deleted successfully.') + + def delete(self, request, *args, **kwargs): + nt = self.get_object() + if ClinicalNote.objects.filter(template=nt).exists(): + messages.error(request, _('Cannot delete a template in use.')) + return redirect('emr:note_template_detail', pk=nt.pk) + + AuditLogEntry.objects.create( + tenant=request.user.tenant, + user=request.user, + action='DELETE', + model_name='NoteTemplate', + object_id=str(nt.pk), + changes={'status': 'Template deleted'} + ) + messages.success(request, self.success_message) + return super().delete(request, *args, **kwargs) + @login_required def start_encounter(request, pk): - enc = get_object_or_404(Encounter, pk=pk, patient__tenant=request.user.tenant) - if enc.status == 'SCHEDULED': + """ + Start a scheduled encounter with proper error handling. + """ + try: + # Get encounter with tenant validation + enc = get_object_or_404( + Encounter, + pk=pk, + patient__tenant=request.user.tenant + ) + + # Check permissions and status + if not request.user.has_perm('emr.change_encounter'): + messages.error(request, _('You do not have permission to start encounters.')) + return redirect('emr:encounter_detail', pk=pk) + + if enc.status != 'SCHEDULED': + messages.error(request, _('Only scheduled encounters can be started.')) + return redirect('emr:encounter_detail', pk=pk) + + # Update encounter status + old_status = enc.status enc.status = 'IN_PROGRESS' enc.save() - AuditLogEntry.objects.create( - tenant=request.user.tenant, user=request.user, - action='UPDATE', model_name='Encounter', - object_id=str(enc.pk), changes={'status': 'Encounter started'} - ) - messages.success(request, _('Encounter started.')) - else: - messages.error(request, _('Only scheduled encounters can be started.')) + + # Log the action + try: + AuditLogEntry.objects.create( + tenant=request.user.tenant, + user=request.user, + action='UPDATE', + model_name='Encounter', + object_id=str(enc.pk), + changes={'status': f'Changed from {old_status} to IN_PROGRESS'} + ) + except Exception as e: + # Log audit failure but don't block the main operation + print(f"Audit logging failed: {e}") + + messages.success(request, _('Encounter started successfully.')) + + except Exception as e: + messages.error(request, _('An error occurred while starting the encounter. Please try again.')) + print(f"Error starting encounter {pk}: {e}") + return redirect('emr:encounter_detail', pk=pk) @@ -1250,19 +1045,33 @@ def complete_encounter(request, pk): @login_required -def resolve_problem(request, pk): - prob = get_object_or_404(ProblemList, pk=pk, patient__tenant=request.user.tenant) +def resolve_problem(request, problem_id): + prob = get_object_or_404(ProblemList, pk=problem_id, patient__tenant=request.user.tenant) if prob.status == 'ACTIVE': prob.status = 'RESOLVED'; prob.save() - AuditLogEntry.objects.create( - tenant=request.user.tenant, user=request.user, - action='UPDATE', model_name='ProblemList', - object_id=str(prob.pk), changes={'status': 'Problem resolved'} - ) + # Log successful creation + try: + AuditLogEntry.objects.create( + tenant=self.request.user.tenant, + user=self.request.user, + event_type='UPDATE', + event_category='PROBLEM_LIST', + action='Resolve Problem', + description=f'Problem resolved for {self.object.patient.get_full_name()}', + content_type=ContentType.objects.get_for_model(ProblemList), + object_id=self.object.pk, + object_repr=str(self.object), + patient_id=str(self.object.patient.patient_id), + patient_mrn=self.object.patient.mrn, + changes={'problem_status': self.object.problem_status, 'status': self.object.status} + ) + except Exception as e: + # Log audit failure but don't block user flow + print(f"Audit logging failed: {e}") messages.success(request, _('Problem resolved.')) else: messages.error(request, _('Only active problems can be resolved.')) - return redirect('emr:problem_detail', pk=pk) + return redirect('emr:problem_detail', pk=prob.pk) @login_required @@ -1310,34 +1119,6 @@ def approve_care_plan(request, pk): return redirect('emr:care_plan_detail', pk=pk) -# @login_required -# def sign_note(request, pk): -# note = get_object_or_404(ClinicalNote, pk=pk, encounter__patient__tenant=request.user.tenant) -# if note.status == 'DRAFT' and note.author == request.user: -# note.status = 'SIGNED'; note.signed_at = timezone.now(); note.save() -# AuditLogEntry.objects.create( -# tenant=request.user.tenant, user=request.user, -# action='UPDATE', model_name='ClinicalNote', -# object_id=str(note.pk), changes={'status': 'Clinical note signed'} -# ) -# messages.success(request, _('Note signed.')) -# else: -# messages.error(request, _('Only your draft notes can be signed.')) -# return redirect('emr:clinical_note_detail', pk=pk) -# -# -# class PatientEMRView(LoginRequiredMixin, DetailView): -# model = PatientProfile -# template_name = 'emr/patient_emr.html' -# pk_url_kwarg = 'patient_id' -# -# def get_queryset(self): -# return PatientProfile.objects.filter(tenant=self.request.user.tenant) -# -# - - - @login_required def vital_signs_search(request): tenant = request.user.tenant @@ -1352,18 +1133,444 @@ def vital_signs_search(request): return render(request, 'emr/partials/vital_signs_search.html', {'vital_signs': vs}) -# class VitalSignsUpdateView(LoginRequiredMixin, FormKwargsMixin, SuccessMessageMixin, TenantMixin, UpdateView): -# model = VitalSigns -# form_class = VitalSignsForm -# template_name = 'emr/vital_signs_form.html' -# success_message = _('Vital signs updated successfully.') -# # implement form_valid, get_success_url, etc. -# -# class VitalSignsDeleteView(LoginRequiredMixin, TenantMixin, SuccessMessageMixin, DeleteView): -# model = VitalSigns -# template_name = 'emr/vital_signs_confirm_delete.html' -# success_url = reverse_lazy('emr:vitalsigns_list') -# success_message = _('Vital signs entry deleted.') +@login_required +def clinical_decision_support(request): + """ + Main view for Clinical Decision Support page. + """ + tenant = request.user.tenant + patient_id = request.GET.get('patient_id') + patient = None + critical_alerts = [] + recommendations = [] + drug_interactions = [] + allergy_alerts = [] + diagnostic_suggestions = [] + treatment_protocols = [] + risk_assessments = [] + clinical_guidelines = [] + + if patient_id: + try: + patient = get_object_or_404( + PatientProfile, + patient_id=patient_id, + tenant=tenant + ) + + # Get critical alerts + critical_alerts = CriticalAlert.objects.filter( + patient=patient, + acknowledged=False + ).order_by('-created_at')[:5] + + # Get clinical recommendations + recommendations = ClinicalRecommendation.objects.filter( + patient=patient, + status__in=['PENDING', 'ACTIVE'] + ).order_by('-priority', '-created_at')[:10] + + # Get allergy alerts + allergy_alerts = AllergyAlert.objects.filter( + patient=patient, + resolved=False + ).order_by('-severity')[:5] + + # Get diagnostic suggestions + diagnostic_suggestions = DiagnosticSuggestion.objects.filter( + patient=patient, + status='ACTIVE' + ).order_by('-confidence')[:5] + + # Get treatment protocols + treatment_protocols = TreatmentProtocol.objects.filter( + is_active=True + ).order_by('-success_rate')[:5] + + # Get relevant clinical guidelines + clinical_guidelines = ClinicalGuideline.objects.filter( + is_active=True + ).order_by('-last_updated')[:6] + + except Exception as e: + messages.error(request, f'Error loading patient data: {str(e)}') + + context = { + 'patient': patient, + 'critical_alerts': critical_alerts, + 'recommendations': recommendations, + 'drug_interactions': drug_interactions, + 'allergy_alerts': allergy_alerts, + 'diagnostic_suggestions': diagnostic_suggestions, + 'treatment_protocols': treatment_protocols, + 'risk_assessments': risk_assessments, + 'clinical_guidelines': clinical_guidelines, + } + + return render(request, 'emr/clinical_decision_support.html', context) + + +@login_required +def patient_search_api(request): + """ + API endpoint for patient search. + """ + tenant = request.user.tenant + query = request.GET.get('q', '').strip() + + if len(query) < 3: + return JsonResponse({'patients': []}) + + patients = PatientProfile.objects.filter( + tenant=tenant + ).filter( + Q(first_name__icontains=query) | + Q(last_name__icontains=query) | + Q(mrn__icontains=query) | + Q(mobile_number__icontains=query) + ).select_related().order_by('last_name', 'first_name')[:10] + + patient_data = [] + for patient in patients: + patient_data.append({ + 'mrn': patient.mrn, + 'patient_id': patient.patient_id, + 'first_name': patient.first_name, + 'last_name': patient.last_name, + 'date_of_birth': patient.date_of_birth.strftime('%Y-%m-%d') if patient.date_of_birth else None, + 'gender': patient.get_gender_display(), + 'mobile_number': patient.mobile_number, + }) + + return JsonResponse({'patients': patient_data}) + + +@login_required +def get_clinical_recommendations(request): + """ + API endpoint to get clinical recommendations for a patient. + """ + tenant = request.user.tenant + patient_id = request.GET.get('patient_id') + + if not patient_id: + return JsonResponse({'error': 'Patient ID required'}, status=400) + + try: + patient = get_object_or_404(PatientProfile, patient_id=patient_id, tenant=tenant) + + recommendations = ClinicalRecommendation.objects.filter( + patient=patient, + status__in=['PENDING', 'ACTIVE'] + ).order_by('-priority', '-created_at')[:20] + + recommendation_data = [] + for rec in recommendations: + recommendation_data.append({ + 'id': str(rec.id), + 'title': rec.title, + 'description': rec.description, + 'category': rec.category, + 'priority': rec.get_priority_display(), + 'evidence_level': rec.evidence_level, + 'source': rec.source, + 'category_display': rec.get_category_display(), + }) + + return JsonResponse({'recommendations': recommendation_data}) + + except Exception as e: + return JsonResponse({'error': str(e)}, status=500) + + +@login_required +def check_drug_interactions(request): + """ + API endpoint to check drug interactions for a patient. + """ + tenant = request.user.tenant + patient_id = request.GET.get('patient_id') + + if not patient_id: + return JsonResponse({'error': 'Patient ID required'}, status=400) + + try: + patient = get_object_or_404(PatientProfile, patient_id=patient_id, tenant=tenant) + + # Get current medications (this would need to be implemented based on pharmacy system) + # For now, return mock data + interaction_data = [ + { + 'id': 'mock-1', + 'drug1': 'Aspirin', + 'drug2': 'Warfarin', + 'severity': 'High', + 'description': 'Increased risk of bleeding', + 'severity_level': 3, + }, + { + 'id': 'mock-2', + 'drug1': 'Lisinopril', + 'drug2': 'Potassium', + 'severity': 'Moderate', + 'description': 'Risk of hyperkalemia', + 'severity_level': 2, + } + ] + + return JsonResponse({'interactions': interaction_data}) + + except Exception as e: + return JsonResponse({'error': str(e)}, status=500) + + +@login_required +def check_allergies(request): + """ + API endpoint to check allergies for a patient. + """ + tenant = request.user.tenant + patient_id = request.GET.get('patient_id') + + if not patient_id: + return JsonResponse({'error': 'Patient ID required'}, status=400) + + try: + patient = get_object_or_404(PatientProfile, patient_id=patient_id, tenant=tenant) + + alerts = AllergyAlert.objects.filter( + patient=patient, + resolved=False + ).order_by('-severity')[:10] + + alert_data = [] + for alert in alerts: + alert_data.append({ + 'id': str(alert.id), + 'allergen': alert.allergen, + 'reaction_type': alert.reaction_type, + 'severity': alert.get_severity_display(), + 'severity_level': alert.severity, + }) + + return JsonResponse({'alerts': alert_data}) + + except Exception as e: + return JsonResponse({'error': str(e)}, status=500) + + +@login_required +def calculate_risk_scores(request): + """ + API endpoint to calculate risk scores for a patient. + """ + tenant = request.user.tenant + patient_id = request.GET.get('patient_id') + + if not patient_id: + return JsonResponse({'error': 'Patient ID required'}, status=400) + + try: + patient = get_object_or_404(PatientProfile, patient_id=patient_id, tenant=tenant) + + # Calculate mock risk scores (this would need to be implemented based on quality system) + risk_data = [ + { + 'id': 'mock-1', + 'name': 'Cardiovascular Risk', + 'score': 15.2, + 'level': 'Moderate', + 'description': '10-year cardiovascular risk assessment', + }, + { + 'id': 'mock-2', + 'name': 'Diabetes Risk', + 'score': 8.5, + 'level': 'Low', + 'description': 'Risk of developing type 2 diabetes', + }, + { + 'id': 'mock-3', + 'name': 'Fall Risk', + 'score': 22.1, + 'level': 'High', + 'description': 'Assessment of fall risk factors', + } + ] + + return JsonResponse({'risk_scores': risk_data}) + + except Exception as e: + return JsonResponse({'error': str(e)}, status=500) + + +@login_required +def accept_recommendation(request): + """ + API endpoint to accept a clinical recommendation. + """ + if request.method != 'POST': + return JsonResponse({'error': 'POST method required'}, status=405) + + tenant = request.user.tenant + recommendation_id = request.POST.get('recommendation_id') + + if not recommendation_id: + return JsonResponse({'error': 'Recommendation ID required'}, status=400) + + try: + recommendation = get_object_or_404( + ClinicalRecommendation, + id=recommendation_id, + patient__tenant=tenant + ) + + recommendation.status = 'ACCEPTED' + recommendation.accepted_by = request.user + recommendation.accepted_at = timezone.now() + recommendation.save() + + return JsonResponse({'success': True}) + + except Exception as e: + return JsonResponse({'error': str(e)}, status=500) + + +@login_required +def defer_recommendation(request): + """ + API endpoint to defer a clinical recommendation. + """ + if request.method != 'POST': + return JsonResponse({'error': 'POST method required'}, status=405) + + tenant = request.user.tenant + recommendation_id = request.POST.get('recommendation_id') + + if not recommendation_id: + return JsonResponse({'error': 'Recommendation ID required'}, status=400) + + try: + recommendation = get_object_or_404( + ClinicalRecommendation, + id=recommendation_id, + patient__tenant=tenant + ) + + recommendation.status = 'DEFERRED' + recommendation.deferred_by = request.user + recommendation.deferred_at = timezone.now() + recommendation.save() + + return JsonResponse({'success': True}) + + except Exception as e: + return JsonResponse({'error': str(e)}, status=500) + + +@login_required +def dismiss_recommendation(request): + """ + API endpoint to dismiss a clinical recommendation. + """ + if request.method != 'POST': + return JsonResponse({'error': 'POST method required'}, status=405) + + tenant = request.user.tenant + recommendation_id = request.POST.get('recommendation_id') + + if not recommendation_id: + return JsonResponse({'error': 'Recommendation ID required'}, status=400) + + try: + recommendation = get_object_or_404( + ClinicalRecommendation, + id=recommendation_id, + patient__tenant=tenant + ) + + recommendation.status = 'DISMISSED' + recommendation.dismissed_by = request.user + recommendation.dismissed_at = timezone.now() + recommendation.save() + + return JsonResponse({'success': True}) + + except Exception as e: + return JsonResponse({'error': str(e)}, status=500) + + +@login_required +def acknowledge_alert(request): + """ + API endpoint to acknowledge a critical alert. + """ + if request.method != 'POST': + return JsonResponse({'error': 'POST method required'}, status=405) + + tenant = request.user.tenant + alert_id = request.POST.get('alert_id') + + if not alert_id: + return JsonResponse({'error': 'Alert ID required'}, status=400) + + try: + alert = get_object_or_404( + CriticalAlert, + id=alert_id, + patient__tenant=tenant + ) + + alert.acknowledged = True + alert.acknowledged_by = request.user + alert.acknowledged_at = timezone.now() + alert.save() + + return JsonResponse({'success': True}) + + except Exception as e: + return JsonResponse({'error': str(e)}, status=500) + + +@login_required +def apply_protocol(request): + """ + API endpoint to apply a treatment protocol. + """ + if request.method != 'POST': + return JsonResponse({'error': 'POST method required'}, status=405) + + tenant = request.user.tenant + protocol_id = request.POST.get('protocol_id') + patient_id = request.POST.get('patient_id') + + if not protocol_id or not patient_id: + return JsonResponse({'error': 'Protocol ID and Patient ID required'}, status=400) + + try: + protocol = get_object_or_404(TreatmentProtocol, id=protocol_id, is_active=True) + patient = get_object_or_404(PatientProfile, patient_id=patient_id, tenant=tenant) + + # Create a care plan based on the protocol + care_plan = CarePlan.objects.create( + tenant=tenant, + patient=patient, + title=f"{protocol.name} - Applied", + description=f"Applied treatment protocol: {protocol.description}", + plan_type='TREATMENT', + category='TREATMENT', + start_date=timezone.now().date(), + primary_provider=request.user, + goals=protocol.goals, + interventions=protocol.interventions, + monitoring_parameters=protocol.monitoring_parameters, + ) + + return JsonResponse({'success': True, 'care_plan_id': str(care_plan.id)}) + + except Exception as e: + return JsonResponse({'error': str(e)}, status=500) + @login_required def get_template_content(request, pk): @@ -1389,29 +1596,124 @@ def emr_stats(request): """ tenant = request.user.tenant today = timezone.now().date() - - stats = { - 'active_encounters': Encounter.objects.filter( - tenant=tenant, - status__in=['ARRIVED', 'TRIAGED', 'IN_PROGRESS', 'ON_HOLD'] - ).count(), - 'pending_documentation': Encounter.objects.filter( - tenant=tenant, - documentation_complete=False, - status='FINISHED' - ).count(), - 'unsigned_notes': ClinicalNote.objects.filter( - patient__tenant=tenant, - status='COMPLETED', - electronically_signed=False - ).count(), - 'critical_vitals': VitalSigns.objects.filter( - patient__tenant=tenant, - measured_datetime__date=today - ).exclude(critical_values=[]).count(), + last_month = today - timedelta(days=30) + + # Base querysets + enc = Encounter.objects.filter(patient__tenant=tenant) + vs = VitalSigns.objects.filter(encounter__patient__tenant=tenant) + pb = ProblemList.objects.filter(patient__tenant=tenant) + cp = CarePlan.objects.filter(patient__tenant=tenant) + cn = ClinicalNote.objects.filter(encounter__patient__tenant=tenant) + providers = User.objects.filter(tenant=tenant, groups__name__in=['Physicians', 'Nurses', 'Providers']) + + # Calculate basic stats + total_encounters = enc.count() + active_encounters = enc.filter(status='IN_PROGRESS').count() + pending_documentation = enc.filter(documentation_complete=False).count() + + # Calculate today's activity + todays_encounters = enc.filter(start_datetime__date=today).count() + completed_today = enc.filter(end_datetime__date=today, status='COMPLETED').count() + vitals_today = vs.filter(measured_datetime__date=today).count() + + # Calculate provider activity + active_providers = providers.filter( + pk__in=enc.filter(start_datetime__date__gte=last_month).values('provider') + ).distinct().count() + + # Calculate performance metrics - calculate wait time from arrival to start + avg_wait_time = 15 # Default value + + # Get encounters with both arrived_datetime and start_datetime + encounters_with_wait_times = enc.filter( + start_datetime__date__gte=last_month, + status__in=['IN_PROGRESS', 'COMPLETED', 'ON_HOLD'] + ).annotate( + wait_time=ExpressionWrapper( + F('start_datetime') - F('created_at'), + output_field=DurationField() + ) + ).exclude(wait_time=None) + + if encounters_with_wait_times.exists(): + total_wait_minutes = 0 + count = 0 + + for encounter in encounters_with_wait_times: + if encounter.wait_time: + total_wait_minutes += encounter.wait_time.total_seconds() / 60 + count += 1 + + if count > 0: + avg_wait_time = total_wait_minutes / count + + # Calculate documentation rate + total_recent = enc.filter(start_datetime__date__gte=last_month).count() + documented_recent = enc.filter( + start_datetime__date__gte=last_month, + documentation_complete=True + ).count() + + documentation_rate = (documented_recent / total_recent * 100) if total_recent > 0 else 0 + + # Calculate encounter efficiency and quality score (mock values for now) + encounter_efficiency = 85.5 + quality_score = 92.3 + + # Calculate month-over-month change + last_month_encounters = enc.filter( + start_datetime__date__gte=last_month - timedelta(days=30), + start_datetime__date__lt=last_month + ).count() + + encounters_change = 0 + if last_month_encounters > 0: + encounters_change = ((total_encounters - last_month_encounters) / last_month_encounters) * 100 + + # Calculate average encounters per provider + avg_encounters_per_provider = 0 + if active_providers > 0: + avg_encounters_per_provider = total_encounters / active_providers + + # Calculate average encounter duration + avg_encounter_duration = 0 + durations = enc.filter( + end_datetime__isnull=False, + start_datetime__isnull=False + ).annotate( + duration=ExpressionWrapper( + F('end_datetime') - F('start_datetime'), + output_field=DurationField() + ) + ).values_list('duration', flat=True) + + if durations.exists(): + total_seconds = sum(d.total_seconds() for d in durations) + avg_encounter_duration = total_seconds / len(durations) / 3600 # Convert to hours + + # Critical alerts (mock value for now) + critical_alerts = 0 + + # Compile all stats + context = { + 'total_encounters': total_encounters, + 'active_encounters': active_encounters, + 'pending_documentation': pending_documentation, + 'critical_alerts': critical_alerts, + 'todays_encounters': todays_encounters, + 'completed_today': completed_today, + 'vitals_today': vitals_today, + 'active_providers': active_providers, + 'avg_encounters_per_provider': avg_encounters_per_provider, + 'documentation_rate': documentation_rate, + 'avg_wait_time': avg_wait_time, + 'encounter_efficiency': encounter_efficiency, + 'quality_score': quality_score, + 'encounters_change': encounters_change, + 'avg_encounter_duration': avg_encounter_duration, } - return render(request, 'emr/partials/emr_stats.html', {'stats': stats}) + return render(request, 'emr/partials/emr_stats.html', context) @login_required @@ -1809,74 +2111,36 @@ class Icd10DetailView(LoginRequiredMixin, DetailView): ctx["children"] = record.children.all().order_by("code") return ctx -# from .utils import search_by_description, search_by_code, get_by_code -# from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger -# -# def icd10_search_view(request): -# """ -# Search ICD-10 by description or code. -# ?q=...&mode=description|code -# """ -# q = (request.GET.get('q') or "").strip() -# mode = (request.GET.get('mode') or "description").strip().lower() -# -# results = [] -# total = 0 -# page_obj = None -# error = None -# -# if q: -# try: -# if mode == "code": -# results = list(search_by_code(q) or []) -# else: -# # default to description search -# results = list(search_by_description(q) or []) -# total = len(results) -# except Exception as exc: -# error = str(exc) -# messages.error(request, f"Search failed: {error}") -# results = [] -# total = 0 -# -# # paginate lists (skip if it looks like a single dict/object) -# if isinstance(results, (list, tuple)): -# paginator = Paginator(results, 20) # 20 per page -# page = request.GET.get('page') -# try: -# page_obj = paginator.get_page(page) -# except (PageNotAnInteger, EmptyPage): -# page_obj = paginator.get_page(1) -# else: -# page_obj = None # single record / non-iterable -# -# context = { -# "query": q, -# "mode": mode, -# "results": results, -# "page_obj": page_obj, -# "total": total, -# "error": error, -# } -# return render(request, "emr/icd10_search.html", context) -# -# def icd10_detail_view(request, code): -# """ -# Exact lookup by code (e.g., A00.0). -# """ -# code = (code or "").strip() -# if not code: -# raise Http404("ICD-10 code is required.") -# -# try: -# record = get_by_code(code) -# if not record: -# raise Http404("ICD-10 code not found.") -# except Exception as exc: -# messages.error(request, f"Lookup failed: {exc}") -# raise Http404("ICD-10 code not found.") -# -# # 'record' could be a dict-like or object; normalize a bit for template -# # We won't assume exact keys; template will render keys/values safely. -# context = {"record": record, "code": code} -# return render(request, "emr/icd10_detail.html", context) \ No newline at end of file + +@login_required +def icd10_search_api(request): + """ + API endpoint for ICD-10 search autocomplete. + """ + query = request.GET.get('q', '').strip() + + if len(query) < 2: + return JsonResponse({'results': []}) + + try: + # Search in description, code, and section name + icd10_records = Icd10.objects.filter( + Q(description__icontains=query) | + Q(code__icontains=query) | + Q(section_name__icontains=query) + ).exclude(is_header=True).order_by('code')[:20] + + results = [] + for record in icd10_records: + results.append({ + 'code': record.code, + 'description': record.description, + 'display': f"{record.code} - {record.description[:100]}{'...' if len(record.description) > 100 else ''}", + 'chapter_name': record.chapter_name, + 'section_name': record.section_name + }) + + return JsonResponse({'results': results}) + + except Exception as e: + return JsonResponse({'error': str(e)}, status=500) diff --git a/emr_data.py b/emr_data.py index 0d2bfc98..0899c778 100644 --- a/emr_data.py +++ b/emr_data.py @@ -8,7 +8,7 @@ django.setup() import random from datetime import datetime, date, time, timedelta from django.utils import timezone as django_timezone -from emr.models import Encounter, VitalSigns, ProblemList, CarePlan, ClinicalNote, NoteTemplate +from emr.models import Encounter, VitalSigns, ProblemList, CarePlan, ClinicalNote, NoteTemplate, Icd10, ClinicalRecommendation, AllergyAlert, TreatmentProtocol, ClinicalGuideline, CriticalAlert, DiagnosticSuggestion from patients.models import PatientProfile from accounts.models import User from hr.models import Employee @@ -500,8 +500,8 @@ def create_encounters(tenants, days_back=30): # Determine status if encounter_date < django_timezone.now().date(): status = random.choices( - ['COMPLETED', 'CANCELLED'], - weights=[85, 15] + ['PLANNED','COMPLETED', 'CANCELLED'], + weights=[30,55, 15] )[0] elif encounter_date == django_timezone.now().date(): status = random.choices( @@ -789,7 +789,7 @@ def create_problem_lists(patients, providers): # Status status = random.choices( - ['ACTIVE', 'RESOLVED', 'INACTIVE', 'CHRONIC'], + ['ACTIVE', 'RESOLVED', 'INACTIVE', 'REMISSION'], weights=[50, 20, 10, 20] )[0] @@ -1205,6 +1205,737 @@ Date: {encounter.start_datetime.strftime('%Y-%m-%d %H:%M')} return clinical_notes +def create_icd10_codes(tenants): + """Create ICD-10 codes for clinical reference""" + icd10_codes = [] + + # Sample ICD-10 codes with descriptions + icd10_data = [ + # Chapter 1: Certain infectious and parasitic diseases + ('A00', 'Cholera', 'Certain infectious and parasitic diseases', 'Cholera and other vibrio infections'), + ('A01', 'Typhoid and paratyphoid fevers', 'Certain infectious and parasitic diseases', 'Typhoid and paratyphoid fevers'), + ('A02', 'Other salmonella infections', 'Certain infectious and parasitic diseases', 'Other salmonella infections'), + ('A03', 'Shigellosis', 'Certain infectious and parasitic diseases', 'Shigellosis'), + ('A04', 'Other bacterial intestinal infections', 'Certain infectious and parasitic diseases', 'Other bacterial intestinal infections'), + + # Chapter 2: Neoplasms + ('C00', 'Malignant neoplasm of lip', 'Neoplasms', 'Malignant neoplasms of lip, oral cavity and pharynx'), + ('C01', 'Malignant neoplasm of base of tongue', 'Neoplasms', 'Malignant neoplasms of lip, oral cavity and pharynx'), + ('C02', 'Malignant neoplasm of other and unspecified parts of tongue', 'Neoplasms', 'Malignant neoplasms of lip, oral cavity and pharynx'), + ('C03', 'Malignant neoplasm of gum', 'Neoplasms', 'Malignant neoplasms of lip, oral cavity and pharynx'), + ('C04', 'Malignant neoplasm of floor of mouth', 'Neoplasms', 'Malignant neoplasms of lip, oral cavity and pharynx'), + + # Chapter 9: Diseases of the circulatory system + ('I00', 'Rheumatic fever without mention of heart involvement', 'Diseases of the circulatory system', 'Acute rheumatic fever'), + ('I01', 'Rheumatic fever with heart involvement', 'Diseases of the circulatory system', 'Acute rheumatic fever'), + ('I02', 'Rheumatic chorea', 'Diseases of the circulatory system', 'Acute rheumatic fever'), + ('I05', 'Rheumatic mitral valve diseases', 'Diseases of the circulatory system', 'Chronic rheumatic heart diseases'), + ('I06', 'Rheumatic aortic valve diseases', 'Diseases of the circulatory system', 'Chronic rheumatic heart diseases'), + + # Chapter 10: Diseases of the respiratory system + ('J00', 'Acute nasopharyngitis [common cold]', 'Diseases of the respiratory system', 'Acute upper respiratory infections'), + ('J01', 'Acute sinusitis', 'Diseases of the respiratory system', 'Acute upper respiratory infections'), + ('J02', 'Acute pharyngitis', 'Diseases of the respiratory system', 'Acute upper respiratory infections'), + ('J03', 'Acute tonsillitis', 'Diseases of the respiratory system', 'Acute upper respiratory infections'), + ('J04', 'Acute laryngitis and tracheitis', 'Diseases of the respiratory system', 'Acute upper respiratory infections'), + + # Chapter 11: Diseases of the digestive system + ('K00', 'Disorders of tooth development and eruption', 'Diseases of the digestive system', 'Disorders of teeth and supporting structures'), + ('K01', 'Embedded and impacted teeth', 'Diseases of the digestive system', 'Disorders of teeth and supporting structures'), + ('K02', 'Dental caries', 'Diseases of the digestive system', 'Disorders of teeth and supporting structures'), + ('K03', 'Other diseases of hard tissues of teeth', 'Diseases of the digestive system', 'Disorders of teeth and supporting structures'), + ('K04', 'Diseases of pulp and periapical tissues', 'Diseases of the digestive system', 'Disorders of teeth and supporting structures'), + + # Chapter 14: Diseases of the genitourinary system + ('N00', 'Acute nephritic syndrome', 'Diseases of the genitourinary system', 'Glomerular diseases'), + ('N01', 'Rapidly progressive nephritic syndrome', 'Diseases of the genitourinary system', 'Glomerular diseases'), + ('N02', 'Recurrent and persistent hematuria', 'Diseases of the genitourinary system', 'Glomerular diseases'), + ('N03', 'Chronic nephritic syndrome', 'Diseases of the genitourinary system', 'Glomerular diseases'), + ('N04', 'Nephrotic syndrome', 'Diseases of the genitourinary system', 'Glomerular diseases'), + + # Chapter 18: Symptoms, signs and abnormal clinical and laboratory findings + ('R00', 'Abnormalities of heart beat', 'Symptoms, signs and abnormal clinical and laboratory findings', 'Symptoms and signs involving the circulatory and respiratory systems'), + ('R01', 'Cardiac murmurs and other cardiac sounds', 'Symptoms, signs and abnormal clinical and laboratory findings', 'Symptoms and signs involving the circulatory and respiratory systems'), + ('R02', 'Gangrene, not elsewhere classified', 'Symptoms, signs and abnormal clinical and laboratory findings', 'Symptoms and signs involving the circulatory and respiratory systems'), + ('R03', 'Abnormal blood-pressure reading, without diagnosis', 'Symptoms, signs and abnormal clinical and laboratory findings', 'Symptoms and signs involving the circulatory and respiratory systems'), + ('R04', 'Haemorrhage from respiratory passages', 'Symptoms, signs and abnormal clinical and laboratory findings', 'Symptoms and signs involving the circulatory and respiratory systems'), + ] + + for tenant in tenants: + for code_data in icd10_data: + code, description, chapter, section = code_data + + # Determine if it's a header (shorter codes are typically headers) + is_header = len(code) <= 3 + + # Create parent relationship for sub-codes + parent = None + if len(code) > 3: + parent_code = code[:3] + try: + parent = Icd10.objects.get(code=parent_code, tenant=tenant) + except Icd10.DoesNotExist: + parent = None + + try: + icd10_code = Icd10.objects.create( + code=code, + description=description, + chapter_name=chapter, + section_name=section, + parent=parent, + is_header=is_header, + created_at=django_timezone.now() - timedelta(days=random.randint(30, 365)), + updated_at=django_timezone.now() - timedelta(days=random.randint(0, 30)) + ) + icd10_codes.append(icd10_code) + except Exception as e: + print(f"Error creating ICD-10 code {code}: {e}") + continue + + print(f"Created {len(icd10_codes)} ICD-10 codes") + return icd10_codes + + +def create_clinical_recommendations(patients, providers, problems, encounters): + """Create clinical recommendations for patients""" + recommendations = [] + + recommendation_templates = [ + { + 'category': 'PREVENTIVE', + 'title': 'Annual Health Screening', + 'description': 'Patient due for annual comprehensive health screening including blood work, imaging, and preventive counseling.', + 'evidence_level': '1A', + 'source': 'USPSTF Guidelines 2023', + 'rationale': 'Regular screening improves early detection and prevention of chronic diseases.', + 'priority': 'MEDIUM' + }, + { + 'category': 'DIAGNOSTIC', + 'title': 'Cardiac Evaluation Recommended', + 'description': 'Consider echocardiography and stress testing given patient history and risk factors.', + 'evidence_level': '1B', + 'source': 'ACC/AHA Guidelines 2022', + 'rationale': 'Patient presents with cardiac symptoms requiring further evaluation.', + 'priority': 'HIGH' + }, + { + 'category': 'TREATMENT', + 'title': 'Optimize Diabetes Management', + 'description': 'Consider adjusting diabetes regimen based on recent HbA1c and glucose readings.', + 'evidence_level': '1A', + 'source': 'ADA Standards of Care 2023', + 'rationale': 'Current glycemic control may be suboptimal requiring treatment intensification.', + 'priority': 'HIGH' + }, + { + 'category': 'MONITORING', + 'title': 'Blood Pressure Monitoring', + 'description': 'Increase frequency of blood pressure monitoring and consider 24-hour ambulatory monitoring.', + 'evidence_level': '1B', + 'source': 'JNC 8 Guidelines', + 'rationale': 'Recent readings suggest need for closer monitoring and possible treatment adjustment.', + 'priority': 'MEDIUM' + }, + { + 'category': 'LIFESTYLE', + 'title': 'Weight Management Program', + 'description': 'Recommend enrollment in structured weight management program with dietary counseling.', + 'evidence_level': '1A', + 'source': 'Obesity Guidelines 2023', + 'rationale': 'Patient BMI indicates need for weight reduction to improve health outcomes.', + 'priority': 'MEDIUM' + }, + { + 'category': 'MEDICATION', + 'title': 'Statin Therapy Consideration', + 'description': 'Consider initiating statin therapy based on cardiovascular risk assessment.', + 'evidence_level': '1A', + 'source': 'ACC/AHA Cholesterol Guidelines', + 'rationale': 'Patient risk factors warrant preventive cardiovascular therapy.', + 'priority': 'HIGH' + }, + { + 'category': 'FOLLOW_UP', + 'title': 'Specialist Consultation', + 'description': 'Refer to cardiology/endocrinology for comprehensive evaluation.', + 'evidence_level': '2A', + 'source': 'Clinical judgment', + 'rationale': 'Complex medical condition requires specialist input.', + 'priority': 'HIGH' + }, + { + 'category': 'EDUCATION', + 'title': 'Diabetes Self-Management Education', + 'description': 'Patient would benefit from structured diabetes education program.', + 'evidence_level': '1A', + 'source': 'ADA Standards', + 'rationale': 'Education improves self-management and clinical outcomes.', + 'priority': 'MEDIUM' + } + ] + + for patient in patients: + # Create 1-3 recommendations per patient + num_recommendations = random.randint(1, 3) + + patient_problems = [p for p in problems if p.patient == patient] + patient_encounters = [e for e in encounters if e.patient == patient] + + for _ in range(num_recommendations): + template = random.choice(recommendation_templates) + + # Generate unique title based on patient context + if patient_problems: + main_problem = random.choice(patient_problems).problem_name + title = f"{template['title']} - {main_problem}" + else: + title = template['title'] + + # Status progression + status = random.choices( + ['PENDING', 'ACTIVE', 'ACCEPTED', 'DEFERRED', 'COMPLETED'], + weights=[40, 30, 15, 10, 5] + )[0] + + # Provider assignments + created_by_provider = random.choice([p for p in providers if p.tenant == patient.tenant]) + + accepted_by = None + accepted_at = None + if status in ['ACCEPTED', 'COMPLETED']: + accepted_by = random.choice([p for p in providers if p.tenant == patient.tenant]) + accepted_at = django_timezone.now() - timedelta(days=random.randint(1, 30)) + + deferred_by = None + deferred_at = None + if status == 'DEFERRED': + deferred_by = random.choice([p for p in providers if p.tenant == patient.tenant]) + deferred_at = django_timezone.now() - timedelta(days=random.randint(1, 14)) + + dismissed_by = None + dismissed_at = None + if status == 'DISMISSED': + dismissed_by = random.choice([p for p in providers if p.tenant == patient.tenant]) + dismissed_at = django_timezone.now() - timedelta(days=random.randint(1, 7)) + + # Expiration + expires_at = django_timezone.now() + timedelta(days=random.randint(30, 180)) + + # Related data + related_problems_list = random.sample( + patient_problems, + min(random.randint(0, 2), len(patient_problems)) + ) if patient_problems else [] + + related_encounter = random.choice(patient_encounters) if patient_encounters else None + + try: + recommendation = ClinicalRecommendation.objects.create( + tenant=patient.tenant, + patient=patient, + recommendation_id=uuid.uuid4(), + title=title, + description=template['description'], + category=template['category'], + priority=template['priority'], + evidence_level=template['evidence_level'], + source=template['source'], + rationale=template['rationale'], + status=status, + accepted_by=accepted_by, + accepted_at=accepted_at, + deferred_by=deferred_by, + deferred_at=deferred_at, + dismissed_by=dismissed_by, + dismissed_at=dismissed_at, + expires_at=expires_at, + related_encounter=related_encounter, + created_by=created_by_provider, + created_at=django_timezone.now() - timedelta(days=random.randint(1, 60)), + updated_at=django_timezone.now() - timedelta(days=random.randint(0, 7)) + ) + + # Set many-to-many relationships + if related_problems_list: + recommendation.related_problems.set(related_problems_list) + + recommendations.append(recommendation) + + except Exception as e: + print(f"Error creating clinical recommendation for {patient.get_full_name()}: {e}") + continue + + print(f"Created {len(recommendations)} clinical recommendations") + return recommendations + + +def create_allergy_alerts(patients, providers): + """Create allergy alerts for patients""" + alerts = [] + + common_allergens = [ + 'Penicillin', 'Aspirin', 'Ibuprofen', 'Codeine', 'Morphine', 'Latex', + 'Shellfish', 'Peanuts', 'Tree Nuts', 'Eggs', 'Milk', 'Soy', + 'Wheat', 'Sulfa Drugs', 'Tetracycline', 'Erythromycin', 'Cephalosporins', + 'NSAIDs', 'Contrast Dye', 'Local Anesthetics', 'Insulin', 'ACE Inhibitors' + ] + + reaction_types = [ + 'Anaphylaxis', 'Angioedema', 'Urticaria', 'Rash', 'Pruritus', + 'Nausea/Vomiting', 'Diarrhea', 'Dyspnea', 'Wheezing', 'Hypotension', + 'Tachycardia', 'Bronchospasm', 'Stevens-Johnson Syndrome', 'Toxic Epidermal Necrolysis' + ] + + severities = ['MILD', 'MODERATE', 'SEVERE', 'LIFE_THREATENING'] + + for patient in patients: + # 20% of patients have allergies + if random.random() > 0.2: + continue + + # Each allergic patient has 1-3 allergies + num_allergies = random.randint(1, 3) + + for _ in range(num_allergies): + allergen = random.choice(common_allergens) + + # Ensure no duplicate allergens for same patient + existing_allergens = [alert.allergen for alert in alerts if alert.patient == patient] + while allergen in existing_allergens: + allergen = random.choice(common_allergens) + + reaction_type = random.choice(reaction_types) + + # Severity based on reaction type + if 'Anaphylaxis' in reaction_type or 'Stevens-Johnson' in reaction_type: + severity = random.choices(severities, weights=[0, 10, 30, 60])[0] + elif 'Angioedema' in reaction_type or 'Bronchospasm' in reaction_type: + severity = random.choices(severities, weights=[10, 30, 40, 20])[0] + else: + severity = random.choices(severities, weights=[40, 40, 15, 5])[0] + + # Symptoms based on reaction type + symptoms = [] + if 'Rash' in reaction_type or 'Urticaria' in reaction_type: + symptoms.extend(['Hives', 'Itching', 'Redness']) + if 'Anaphylaxis' in reaction_type: + symptoms.extend(['Difficulty breathing', 'Swelling of throat', 'Dizziness', 'Nausea']) + if 'Angioedema' in reaction_type: + symptoms.extend(['Facial swelling', 'Tongue swelling', 'Lip swelling']) + if 'Dyspnea' in reaction_type or 'Wheezing' in reaction_type: + symptoms.extend(['Shortness of breath', 'Wheezing', 'Chest tightness']) + + symptoms = symptoms[:random.randint(1, 4)] if symptoms else ['Unknown symptoms'] + + # Onset timing + onset = random.choice([ + 'Immediate (< 1 hour)', 'Early (1-6 hours)', 'Delayed (6-24 hours)', + 'Late (> 24 hours)', 'Unknown' + ]) + + # Status + resolved = random.choice([True, False]) + resolved_at = django_timezone.now() - timedelta(days=random.randint(1, 365)) if resolved else None + resolved_by = random.choice([p for p in providers if p.tenant == patient.tenant]) if resolved else None + + try: + alert = AllergyAlert.objects.create( + tenant=patient.tenant, + patient=patient, + alert_id=uuid.uuid4(), + allergen=allergen, + reaction_type=reaction_type, + severity=severity, + symptoms=symptoms, + onset=onset, + resolved=resolved, + resolved_at=resolved_at, + resolved_by=resolved_by, + detected_at=django_timezone.now() - timedelta(days=random.randint(1, 3650)), + created_at=django_timezone.now() - timedelta(days=random.randint(1, 3650)), + updated_at=django_timezone.now() - timedelta(days=random.randint(0, 30)) + ) + alerts.append(alert) + + except Exception as e: + print(f"Error creating allergy alert for {patient.get_full_name()}: {e}") + continue + + print(f"Created {len(alerts)} allergy alerts") + return alerts + + +def create_treatment_protocols(tenants, providers): + """Create treatment protocols""" + protocols = [] + + protocol_templates = [ + { + 'name': 'Acute Coronary Syndrome Management', + 'indication': 'Management of patients with suspected or confirmed acute coronary syndrome', + 'goals': ['Rapid reperfusion', 'Pain control', 'Complication prevention'], + 'interventions': ['ECG within 10 minutes', 'Cardiac enzymes', 'Antiplatelet therapy', 'Anticoagulation'], + 'monitoring_parameters': ['ECG changes', 'Cardiac enzymes', 'Vital signs', 'Pain assessment'], + 'success_rate': 85.0, + 'average_duration': 7 + }, + { + 'name': 'Diabetes Mellitus Type 2 Management', + 'indication': 'Comprehensive management of type 2 diabetes mellitus', + 'goals': ['Glycemic control (HbA1c < 7%)', 'Cardiovascular risk reduction', 'Prevention of complications'], + 'interventions': ['Lifestyle modification', 'Oral hypoglycemics', 'Insulin therapy', 'Regular monitoring'], + 'monitoring_parameters': ['HbA1c', 'Fasting glucose', 'Blood pressure', 'Lipid profile'], + 'success_rate': 75.0, + 'average_duration': 365 + }, + { + 'name': 'Community Acquired Pneumonia Treatment', + 'indication': 'Treatment of community-acquired pneumonia in adults', + 'goals': ['Clinical cure', 'Prevention of complications', 'Appropriate antibiotic use'], + 'interventions': ['Appropriate antibiotics', 'Supportive care', 'Vaccination assessment'], + 'monitoring_parameters': ['Clinical improvement', 'Fever resolution', 'Oxygen saturation'], + 'success_rate': 90.0, + 'average_duration': 10 + }, + { + 'name': 'Hypertension Management Protocol', + 'indication': 'Management of essential hypertension', + 'goals': ['BP control (< 130/80 mmHg)', 'Cardiovascular risk reduction', 'Organ protection'], + 'interventions': ['Lifestyle modification', 'Antihypertensive therapy', 'Regular monitoring'], + 'monitoring_parameters': ['Blood pressure', 'Electrolyte levels', 'Renal function'], + 'success_rate': 70.0, + 'average_duration': 180 + }, + { + 'name': 'Acute Asthma Exacerbation Protocol', + 'indication': 'Management of acute asthma exacerbations', + 'goals': ['Rapid symptom relief', 'Prevention of respiratory failure', 'Hospitalization prevention'], + 'interventions': ['Bronchodilators', 'Systemic corticosteroids', 'Oxygen therapy', 'Monitoring'], + 'monitoring_parameters': ['Peak flow', 'Oxygen saturation', 'Respiratory rate', 'Clinical symptoms'], + 'success_rate': 88.0, + 'average_duration': 3 + } + ] + + for tenant in tenants: + for template in protocol_templates: + try: + protocol = TreatmentProtocol.objects.create( + tenant=tenant, + protocol_id=uuid.uuid4(), + name=template['name'], + description=f"Standardized protocol for {template['name'].lower()}", + indication=template['indication'], + goals=template['goals'], + interventions=template['interventions'], + monitoring_parameters=template['monitoring_parameters'], + success_rate=Decimal(str(template['success_rate'])), + average_duration=template['average_duration'], + is_active=True, + usage_count=random.randint(10, 200), + created_at=django_timezone.now() - timedelta(days=random.randint(30, 365)), + updated_at=django_timezone.now() - timedelta(days=random.randint(0, 30)), + created_by=random.choice([p for p in providers if p.tenant == tenant]) + ) + protocols.append(protocol) + except Exception as e: + print(f"Error creating treatment protocol {template['name']}: {e}") + continue + + print(f"Created {len(protocols)} treatment protocols") + return protocols + + +def create_clinical_guidelines(tenants): + """Create clinical guidelines""" + guidelines = [] + + guideline_templates = [ + { + 'title': 'Management of Heart Failure', + 'organization': 'American Heart Association', + 'summary': 'Comprehensive guidelines for the diagnosis and management of heart failure', + 'publication_date': date(2022, 4, 1), + 'version': '2022', + 'keywords': ['heart failure', 'cardiology', 'HFrEF', 'HFpEF'], + 'specialties': ['Cardiology', 'Internal Medicine', 'Family Medicine'] + }, + { + 'title': 'Standards of Medical Care in Diabetes', + 'organization': 'American Diabetes Association', + 'summary': 'Comprehensive guidelines for the diagnosis and management of diabetes mellitus', + 'publication_date': date(2023, 1, 1), + 'version': '2023', + 'keywords': ['diabetes', 'glycemia', 'complications', 'prevention'], + 'specialties': ['Endocrinology', 'Internal Medicine', 'Family Medicine'] + }, + { + 'title': 'Guidelines for the Prevention, Detection, Evaluation, and Management of High Blood Pressure', + 'organization': 'American College of Cardiology/American Heart Association', + 'summary': 'Evidence-based guidelines for hypertension management', + 'publication_date': date(2017, 11, 13), + 'version': '2017', + 'keywords': ['hypertension', 'blood pressure', 'cardiovascular', 'risk'], + 'specialties': ['Cardiology', 'Internal Medicine', 'Nephrology'] + }, + { + 'title': 'Global Initiative for Asthma (GINA) Report', + 'organization': 'Global Initiative for Asthma', + 'summary': 'Global strategy for asthma management and prevention', + 'publication_date': date(2023, 5, 1), + 'version': '2023', + 'keywords': ['asthma', 'respiratory', 'inhalers', 'control'], + 'specialties': ['Pulmonology', 'Allergy', 'Pediatrics'] + }, + { + 'title': 'Prevention and Management of Osteoporosis', + 'organization': 'World Health Organization', + 'summary': 'Guidelines for osteoporosis prevention and treatment', + 'publication_date': date(2022, 1, 1), + 'version': '2022', + 'keywords': ['osteoporosis', 'bone density', 'fracture', 'calcium'], + 'specialties': ['Endocrinology', 'Rheumatology', 'Geriatrics'] + } + ] + + for tenant in tenants: + for template in guideline_templates: + try: + guideline = ClinicalGuideline.objects.create( + tenant=tenant, + guideline_id=uuid.uuid4(), + title=template['title'], + organization=template['organization'], + summary=template['summary'], + url=f"https://example.com/guidelines/{template['title'].lower().replace(' ', '_')}", + publication_date=template['publication_date'], + last_updated=django_timezone.now().date() - timedelta(days=random.randint(0, 365)), + version=template['version'], + is_active=True, + keywords=template['keywords'], + specialties=template['specialties'], + created_at=django_timezone.now() - timedelta(days=random.randint(30, 365)), + updated_at=django_timezone.now() - timedelta(days=random.randint(0, 30)) + ) + guidelines.append(guideline) + except Exception as e: + print(f"Error creating clinical guideline {template['title']}: {e}") + continue + + print(f"Created {len(guidelines)} clinical guidelines") + return guidelines + + +def create_critical_alerts(patients, providers, encounters): + """Create critical alerts for patients""" + alerts = [] + + alert_templates = [ + { + 'title': 'Critical Lab Value Alert', + 'description': 'Potassium level critically elevated at 7.2 mEq/L', + 'priority': 'CRITICAL', + 'recommendation': 'Immediate treatment with calcium gluconate, insulin, and glucose. Transfer to ICU.' + }, + { + 'title': 'Acute Coronary Syndrome Alert', + 'description': 'ST-elevation myocardial infarction detected on ECG', + 'priority': 'CRITICAL', + 'recommendation': 'Activate cardiac catheterization lab. Administer aspirin, heparin, and prepare for PCI.' + }, + { + 'title': 'Severe Hypoglycemia Alert', + 'description': 'Blood glucose critically low at 35 mg/dL', + 'priority': 'CRITICAL', + 'recommendation': 'Administer IV dextrose immediately. Recheck glucose in 15 minutes.' + }, + { + 'title': 'Acute Stroke Alert', + 'description': 'Suspected acute ischemic stroke with NIHSS score of 18', + 'priority': 'URGENT', + 'recommendation': 'Activate stroke team. Obtain CT brain immediately. Prepare for thrombolytic therapy.' + }, + { + 'title': 'Sepsis Alert', + 'description': 'SIRS criteria met with suspected infection. Lactate elevated at 4.2 mmol/L', + 'priority': 'URGENT', + 'recommendation': 'Obtain blood cultures, start broad-spectrum antibiotics within 1 hour, fluid resuscitation.' + }, + { + 'title': 'Drug Interaction Alert', + 'description': 'Critical drug interaction between warfarin and newly prescribed antibiotic', + 'priority': 'HIGH', + 'recommendation': 'Monitor INR closely. Consider dose adjustment or alternative antibiotic.' + }, + { + 'title': 'Allergy Alert - Severe Reaction', + 'description': 'Patient with history of anaphylaxis to penicillin now prescribed amoxicillin', + 'priority': 'HIGH', + 'recommendation': 'Discontinue amoxicillin. Prescribe alternative antibiotic. Consider premedication if necessary.' + } + ] + + for patient in patients: + # 5% of patients have critical alerts + if random.random() > 0.05: + continue + + # Each patient with alerts has 1-2 critical alerts + num_alerts = random.randint(1, 2) + + patient_encounters = [e for e in encounters if e.patient == patient] + + for _ in range(num_alerts): + template = random.choice(alert_templates) + + # Status + acknowledged = random.choice([True, False]) + acknowledged_by = random.choice([p for p in providers if p.tenant == patient.tenant]) if acknowledged else None + acknowledged_at = django_timezone.now() - timedelta(hours=random.randint(1, 24)) if acknowledged else None + + # Expiration + expires_at = django_timezone.now() + timedelta(hours=random.randint(24, 168)) + + # Related encounter + related_encounter = random.choice(patient_encounters) if patient_encounters else None + + try: + alert = CriticalAlert.objects.create( + tenant=patient.tenant, + patient=patient, + alert_id=uuid.uuid4(), + title=template['title'], + description=template['description'], + priority=template['priority'], + recommendation=template['recommendation'], + acknowledged=acknowledged, + acknowledged_by=acknowledged_by, + acknowledged_at=acknowledged_at, + expires_at=expires_at, + related_encounter=related_encounter, + created_by=random.choice([p for p in providers if p.tenant == patient.tenant]), + created_at=django_timezone.now() - timedelta(hours=random.randint(1, 48)), + updated_at=django_timezone.now() - timedelta(minutes=random.randint(0, 60)) + ) + alerts.append(alert) + + except Exception as e: + print(f"Error creating critical alert for {patient.get_full_name()}: {e}") + continue + + print(f"Created {len(alerts)} critical alerts") + return alerts + + +def create_diagnostic_suggestions(patients, providers, encounters): + """Create diagnostic suggestions for patients""" + suggestions = [] + + suggestion_templates = [ + { + 'test_name': 'Echocardiogram', + 'test_code': 'ECHO', + 'indication': 'Evaluate cardiac function and structure', + 'confidence': 85.0 + }, + { + 'test_name': 'Coronary Angiography', + 'test_code': 'CATH', + 'indication': 'Assess coronary artery disease', + 'confidence': 78.0 + }, + { + 'test_name': 'MRI Brain', + 'test_code': 'MRI_BRAIN', + 'indication': 'Evaluate neurological symptoms', + 'confidence': 72.0 + }, + { + 'test_name': 'CT Chest', + 'test_code': 'CT_CHEST', + 'indication': 'Assess pulmonary pathology', + 'confidence': 80.0 + }, + { + 'test_name': 'Colonoscopy', + 'test_code': 'COLON', + 'indication': 'Screen for colorectal cancer', + 'confidence': 88.0 + }, + { + 'test_name': 'DEXA Scan', + 'test_code': 'DEXA', + 'indication': 'Assess bone mineral density', + 'confidence': 90.0 + }, + { + 'test_name': 'Thyroid Function Tests', + 'test_code': 'TFT', + 'indication': 'Evaluate thyroid dysfunction', + 'confidence': 75.0 + }, + { + 'test_name': 'HbA1c', + 'test_code': 'HBA1C', + 'indication': 'Assess long-term glycemic control', + 'confidence': 95.0 + } + ] + + for patient in patients: + # 15% of patients have diagnostic suggestions + if random.random() > 0.15: + continue + + # Each patient with suggestions has 1-3 suggestions + num_suggestions = random.randint(1, 3) + + patient_encounters = [e for e in encounters if e.patient == patient] + + for _ in range(num_suggestions): + template = random.choice(suggestion_templates) + + # Status + status = random.choices( + ['PENDING', 'ORDERED', 'COMPLETED'], + weights=[50, 30, 20] + )[0] + + # Provider assignments + ordered_by = None + ordered_at = None + if status in ['ORDERED', 'COMPLETED']: + ordered_by = random.choice([p for p in providers if p.tenant == patient.tenant]) + ordered_at = django_timezone.now() - timedelta(days=random.randint(1, 14)) + + try: + suggestion = DiagnosticSuggestion.objects.create( + tenant=patient.tenant, + patient=patient, + suggestion_id=uuid.uuid4(), + test_name=template['test_name'], + test_code=template['test_code'], + indication=template['indication'], + confidence=Decimal(str(template['confidence'])), + status=status, + ordered_by=ordered_by, + ordered_at=ordered_at, + created_by=random.choice([p for p in providers if p.tenant == patient.tenant]), + created_at=django_timezone.now() - timedelta(days=random.randint(1, 30)), + updated_at=django_timezone.now() - timedelta(days=random.randint(0, 7)) + ) + suggestions.append(suggestion) + + except Exception as e: + print(f"Error creating diagnostic suggestion for {patient.get_full_name()}: {e}") + continue + + print(f"Created {len(suggestions)} diagnostic suggestions") + return suggestions + + def main(): """Main function to generate all EMR data""" print("Starting Saudi Healthcare EMR Data Generation...") @@ -1243,6 +1974,34 @@ def main(): print("\n6. Creating Clinical Notes...") clinical_notes = create_clinical_notes(encounters, templates) + # Create ICD-10 codes + print("\n7. Creating ICD-10 Codes...") + icd10_codes = create_icd10_codes(tenants) + + # Create clinical recommendations + print("\n8. Creating Clinical Recommendations...") + clinical_recommendations = create_clinical_recommendations(patients, providers, problems, encounters) + + # Create allergy alerts + print("\n9. Creating Allergy Alerts...") + allergy_alerts = create_allergy_alerts(patients, providers) + + # Create treatment protocols + print("\n10. Creating Treatment Protocols...") + treatment_protocols = create_treatment_protocols(tenants, providers) + + # Create clinical guidelines + print("\n11. Creating Clinical Guidelines...") + clinical_guidelines = create_clinical_guidelines(tenants) + + # Create critical alerts + print("\n12. Creating Critical Alerts...") + critical_alerts = create_critical_alerts(patients, providers, encounters) + + # Create diagnostic suggestions + print("\n13. Creating Diagnostic Suggestions...") + diagnostic_suggestions = create_diagnostic_suggestions(patients, providers, encounters) + print(f"\n✅ Saudi Healthcare EMR Data Generation Complete!") print(f"📊 Summary:") print(f" - Note Templates: {len(templates)}") @@ -1251,6 +2010,13 @@ def main(): print(f" - Problem List Entries: {len(problems)}") print(f" - Care Plans: {len(care_plans)}") print(f" - Clinical Notes: {len(clinical_notes)}") + print(f" - ICD-10 Codes: {len(icd10_codes)}") + print(f" - Clinical Recommendations: {len(clinical_recommendations)}") + print(f" - Allergy Alerts: {len(allergy_alerts)}") + print(f" - Treatment Protocols: {len(treatment_protocols)}") + print(f" - Clinical Guidelines: {len(clinical_guidelines)}") + print(f" - Critical Alerts: {len(critical_alerts)}") + print(f" - Diagnostic Suggestions: {len(diagnostic_suggestions)}") # Statistics if encounters: @@ -1277,9 +2043,16 @@ def main(): 'vital_signs': vital_signs, 'problems': problems, 'care_plans': care_plans, - 'clinical_notes': clinical_notes + 'clinical_notes': clinical_notes, + 'icd10_codes': icd10_codes, + 'clinical_recommendations': clinical_recommendations, + 'allergy_alerts': allergy_alerts, + 'treatment_protocols': treatment_protocols, + 'clinical_guidelines': clinical_guidelines, + 'critical_alerts': critical_alerts, + 'diagnostic_suggestions': diagnostic_suggestions } if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/facility_management/__pycache__/admin.cpython-312.pyc b/facility_management/__pycache__/admin.cpython-312.pyc index d5a708e6..caa33749 100644 Binary files a/facility_management/__pycache__/admin.cpython-312.pyc and b/facility_management/__pycache__/admin.cpython-312.pyc differ diff --git a/facility_management/__pycache__/forms.cpython-312.pyc b/facility_management/__pycache__/forms.cpython-312.pyc index 9d342702..90335704 100644 Binary files a/facility_management/__pycache__/forms.cpython-312.pyc and b/facility_management/__pycache__/forms.cpython-312.pyc differ diff --git a/facility_management/__pycache__/models.cpython-312.pyc b/facility_management/__pycache__/models.cpython-312.pyc index 098825ea..b5c15c6c 100644 Binary files a/facility_management/__pycache__/models.cpython-312.pyc and b/facility_management/__pycache__/models.cpython-312.pyc differ diff --git a/facility_management/__pycache__/views.cpython-312.pyc b/facility_management/__pycache__/views.cpython-312.pyc index d48f1407..ed5506ff 100644 Binary files a/facility_management/__pycache__/views.cpython-312.pyc and b/facility_management/__pycache__/views.cpython-312.pyc differ diff --git a/facility_management/admin.py b/facility_management/admin.py index 8c38f3f3..29efbe7a 100644 --- a/facility_management/admin.py +++ b/facility_management/admin.py @@ -1,3 +1,264 @@ from django.contrib import admin +from .models import ( + Building, Floor, Room, AssetCategory, Asset, MaintenanceType, + MaintenanceRequest, MaintenanceSchedule, Vendor, ServiceContract, + Inspection, EnergyMeter, EnergyReading, SpaceReservation +) -# Register your models here. + +@admin.register(Building) +class BuildingAdmin(admin.ModelAdmin): + list_display = ['code', 'name', 'building_type', 'floor_count', 'total_area_sqm', 'is_active', 'facility_manager'] + list_filter = ['building_type', 'is_active', 'construction_year', 'tenant'] + search_fields = ['code', 'name', 'address'] + ordering = ['code', 'name'] + readonly_fields = ['building_id', 'created_at', 'updated_at'] + fieldsets = ( + ('Basic Information', { + 'fields': ('tenant', 'name', 'building_id', 'code', 'building_type') + }), + ('Location & Dimensions', { + 'fields': ('address', 'latitude', 'longitude', 'floor_count', 'total_area_sqm') + }), + ('Construction Details', { + 'fields': ('construction_year', 'architect', 'contractor', 'last_major_renovation') + }), + ('Management', { + 'fields': ('facility_manager', 'is_active') + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + +@admin.register(Floor) +class FloorAdmin(admin.ModelAdmin): + list_display = ['building', 'floor_number', 'name', 'area_sqm', 'is_public_access'] + list_filter = ['building', 'is_public_access'] + search_fields = ['name', 'building__name', 'building__code'] + ordering = ['building', 'floor_number'] + + +@admin.register(Room) +class RoomAdmin(admin.ModelAdmin): + list_display = ['room_number', 'name', 'floor', 'occupancy_status', 'area_sqm', 'capacity', 'is_accessible'] + list_filter = ['occupancy_status', 'is_accessible', 'floor__building'] + search_fields = ['room_number', 'name', 'floor__building__name'] + ordering = ['floor', 'room_number'] + readonly_fields = ['created_at', 'updated_at'] + + +@admin.register(AssetCategory) +class AssetCategoryAdmin(admin.ModelAdmin): + list_display = ['name', 'code', 'parent_category', 'is_active'] + list_filter = ['is_active'] + search_fields = ['name', 'code', 'description'] + ordering = ['name'] + + +@admin.register(Asset) +class AssetAdmin(admin.ModelAdmin): + list_display = ['name', 'category', 'building', 'status', 'condition', 'assigned_to'] + list_filter = ['status', 'condition', 'category', 'building'] + search_fields = [ 'name', 'serial_number', 'manufacturer', 'model'] + ordering = ['asset_id'] + readonly_fields = ['asset_id', 'created_at', 'updated_at'] + fieldsets = ( + ('Basic Information', { + 'fields': ('name', 'category') + }), + ('Location', { + 'fields': ('building', 'floor', 'room', 'location_description') + }), + ('Asset Details', { + 'fields': ('manufacturer', 'model', 'serial_number') + }), + ('Financial Information', { + 'fields': ('purchase_date', 'purchase_cost', 'current_value', 'depreciation_rate') + }), + ('Warranty & Service', { + 'fields': ('warranty_start_date', 'warranty_end_date', 'service_provider', 'service_contract_number') + }), + ('Status & Condition', { + 'fields': ('status', 'condition', 'last_inspection_date', 'next_maintenance_date') + }), + ('Assignment', { + 'fields': ('assigned_to', 'notes') + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + def is_under_warranty(self, obj): + return obj.is_under_warranty + is_under_warranty.boolean = True + is_under_warranty.short_description = 'Under Warranty' + + def needs_maintenance(self, obj): + return obj.needs_maintenance + needs_maintenance.boolean = True + needs_maintenance.short_description = 'Needs Maintenance' + + +@admin.register(MaintenanceType) +class MaintenanceTypeAdmin(admin.ModelAdmin): + list_display = ['name', 'code', 'estimated_duration_hours', 'is_active'] + list_filter = ['is_active'] + search_fields = ['name', 'code', 'description'] + ordering = ['name'] + + +@admin.register(MaintenanceRequest) +class MaintenanceRequestAdmin(admin.ModelAdmin): + list_display = ['request_id', 'title', 'maintenance_type', 'building', 'priority', 'status', 'requested_by', 'assigned_to', 'requested_date'] + list_filter = ['priority', 'status', 'maintenance_type', 'building', 'requested_date'] + search_fields = ['request_id', 'title', 'description', 'requested_by__username'] + ordering = ['-requested_date'] + # readonly_fields = ['request_id', 'created_at', 'updated_at'] + fieldsets = ( + ('Basic Information', { + 'fields': ('request_id', 'title', 'description', 'maintenance_type') + }), + ('Location', { + 'fields': ('building', 'floor', 'room', 'asset') + }), + ('Request Details', { + 'fields': ('priority', 'status') + }), + ('People Involved', { + 'fields': ('requested_by', 'assigned_to') + }), + ('Timing', { + 'fields': ('requested_date', 'scheduled_date', 'started_date', 'completed_date') + }), + ('Cost Estimation', { + 'fields': ('estimated_hours', 'estimated_cost', 'actual_cost') + }), + ('Additional Information', { + 'fields': ('notes', 'completion_notes') + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + +@admin.register(MaintenanceSchedule) +class MaintenanceScheduleAdmin(admin.ModelAdmin): + list_display = ['name', 'maintenance_type', 'asset', 'building', 'frequency_interval', 'next_due_date', 'is_active'] + list_filter = ['is_active', 'frequency_interval', 'maintenance_type'] + search_fields = ['name', 'description'] + ordering = ['next_due_date'] + readonly_fields = ['created_at'] + + +@admin.register(Vendor) +class VendorAdmin(admin.ModelAdmin): + list_display = ['name', 'vendor_type', 'contact_person', 'email', 'phone', 'rating', 'is_active'] + list_filter = ['vendor_type', 'is_active', 'tenant'] + search_fields = ['name', 'contact_person', 'email', 'crn', 'vrn'] + ordering = ['name'] + readonly_fields = ['created_at', 'updated_at'] + + +@admin.register(ServiceContract) +class ServiceContractAdmin(admin.ModelAdmin): + list_display = ['contract_number', 'vendor', 'title', 'start_date', 'end_date', 'status', 'contract_value'] + list_filter = ['status', 'vendor', 'start_date', 'end_date'] + search_fields = ['contract_number', 'title', 'vendor__name'] + ordering = ['-start_date'] + readonly_fields = ['created_at', 'updated_at'] + fieldsets = ( + ('Basic Information', { + 'fields': ('contract_number', 'vendor', 'title', 'description') + }), + ('Contract Terms', { + 'fields': ('start_date', 'end_date', 'contract_value', 'payment_terms') + }), + ('Scope', { + 'fields': ('buildings', 'service_areas') + }), + ('Status', { + 'fields': ('status', 'auto_renewal', 'renewal_notice_days') + }), + ('Management', { + 'fields': ('contract_manager', 'notes') + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + def is_expiring_soon(self, obj): + return obj.is_expiring_soon + is_expiring_soon.boolean = True + is_expiring_soon.short_description = 'Expiring Soon' + + +@admin.register(Inspection) +class InspectionAdmin(admin.ModelAdmin): + list_display = ['inspection_id', 'inspection_type', 'title', 'building', 'scheduled_date', 'status', 'inspector'] + list_filter = ['inspection_type', 'status', 'building', 'scheduled_date'] + search_fields = ['inspection_id', 'title', 'inspector__username'] + ordering = ['-scheduled_date'] + readonly_fields = ['inspection_id', 'created_at', 'updated_at'] + + +@admin.register(EnergyMeter) +class EnergyMeterAdmin(admin.ModelAdmin): + list_display = ['meter_id', 'meter_type', 'building', 'current_reading', 'last_reading_date', 'is_active'] + list_filter = ['meter_type', 'building', 'is_active'] + search_fields = ['meter_id', 'serial_number', 'building__name'] + ordering = ['building', 'meter_type', 'meter_id'] + readonly_fields = ['created_at', 'updated_at'] + + +@admin.register(EnergyReading) +class EnergyReadingAdmin(admin.ModelAdmin): + list_display = ['meter', 'reading_date', 'reading_value', 'consumption', 'cost', 'is_estimated'] + list_filter = ['reading_date', 'is_estimated', 'meter__meter_type'] + search_fields = ['meter__meter_id', 'read_by__username'] + ordering = ['-reading_date'] + readonly_fields = ['created_at'] + + +@admin.register(SpaceReservation) +class SpaceReservationAdmin(admin.ModelAdmin): + list_display = ['reservation_id', 'title', 'room', 'start_datetime', 'end_datetime', 'status', 'reserved_by'] + list_filter = ['status', 'start_datetime', 'room__floor__building'] + search_fields = ['reservation_id', 'title', 'reserved_by__username', 'contact_person'] + ordering = ['-start_datetime'] + readonly_fields = ['reservation_id', 'created_at', 'updated_at'] + fieldsets = ( + ('Basic Information', { + 'fields': ('reservation_id', 'room', 'title', 'description') + }), + ('Schedule', { + 'fields': ('start_datetime', 'end_datetime') + }), + ('Requester Information', { + 'fields': ('reserved_by', 'contact_person', 'contact_email', 'contact_phone') + }), + ('Event Details', { + 'fields': ('expected_attendees', 'setup_requirements', 'catering_required', 'av_equipment_required') + }), + ('Status & Approval', { + 'fields': ('status', 'approved_by', 'approved_at') + }), + ('Billing', { + 'fields': ('hourly_rate', 'total_cost') + }), + ('Notes', { + 'fields': ('notes',) + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) diff --git a/facility_management/forms.py b/facility_management/forms.py index 34b77050..69323da3 100644 --- a/facility_management/forms.py +++ b/facility_management/forms.py @@ -124,6 +124,7 @@ class AssetForm(forms.ModelForm): 'service_contract_number', 'status', 'condition', 'last_inspection_date', 'next_maintenance_date', 'assigned_to', 'notes' ] + exclude = ['asset_id'] widgets = { 'name': forms.TextInput(attrs={'class': 'form-control'}), 'category': forms.Select(attrs={'class': 'form-select'}), diff --git a/facility_management/migrations/0001_initial.py b/facility_management/migrations/0001_initial.py index df1984d8..7b7a96e7 100644 --- a/facility_management/migrations/0001_initial.py +++ b/facility_management/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-09-19 10:58 +# Generated by Django 5.2.6 on 2025-09-26 18:33 import django.core.validators import django.db.models.deletion diff --git a/facility_management/migrations/__pycache__/0001_initial.cpython-312.pyc b/facility_management/migrations/__pycache__/0001_initial.cpython-312.pyc index a4bc0fd1..864953d6 100644 Binary files a/facility_management/migrations/__pycache__/0001_initial.cpython-312.pyc and b/facility_management/migrations/__pycache__/0001_initial.cpython-312.pyc differ diff --git a/facility_management/models.py b/facility_management/models.py index 6a06289f..116552a5 100644 --- a/facility_management/models.py +++ b/facility_management/models.py @@ -189,7 +189,7 @@ class Asset(models.Model): # Status and condition status = models.CharField(max_length=20, choices=AssetStatus.choices, default=AssetStatus.OPERATIONAL) - condition = models.CharField(max_length=20, choices=AssetCondition, default=AssetCondition.GOOD, help_text="Current condition of the asset") + condition = models.CharField(max_length=20, choices=AssetCondition.choices, default=AssetCondition.GOOD, help_text="Current condition of the asset") last_inspection_date = models.DateField(null=True, blank=True) next_maintenance_date = models.DateField(null=True, blank=True) diff --git a/facility_management/templates/facility_management/dashboard.html b/facility_management/templates/facility_management/dashboard.html index e8b0cfad..e624a71e 100644 --- a/facility_management/templates/facility_management/dashboard.html +++ b/facility_management/templates/facility_management/dashboard.html @@ -27,9 +27,7 @@ {% endblock %} -{% block js %} -{{ block.super }} -{% endblock %} + {% block content %}
@@ -293,15 +291,7 @@
-
-
+
@@ -315,21 +305,13 @@
-
-
+
-
+

{{ _("Quick Actions")}}

@@ -359,3 +341,47 @@ {% endblock %} +{% block js %} + + + + +{% endblock %} diff --git a/facility_management/views.py b/facility_management/views.py index 1b2296c3..4cae3f27 100644 --- a/facility_management/views.py +++ b/facility_management/views.py @@ -31,11 +31,11 @@ class FacilityDashboardView(LoginRequiredMixin, TemplateView): 'total_assets': Asset.objects.count(), 'operational_assets': Asset.objects.filter(status=Asset.AssetStatus.OPERATIONAL).count(), 'assets_needing_maintenance': Asset.objects.filter(next_maintenance_date__lte=today).count(), - 'open_work_orders': MaintenanceRequest.objects.filter(status__in=['submitted', 'assigned', 'in_progress']).count(), + 'open_work_orders': MaintenanceRequest.objects.filter(status__in=['SUBMITTED', 'ASSIGNED', 'IN_PROGRESS']).count(), 'completed_today': MaintenanceRequest.objects.filter(completed_date__date=today).count(), 'total_rooms': Room.objects.count(), - 'occupied_rooms': Room.objects.filter(occupancy_status='occupied').count(), - 'active_contracts': ServiceContract.objects.filter(status='active').count(), + 'occupied_rooms': Room.objects.filter(occupancy_status='OCCUPIED').count(), + 'active_contracts': ServiceContract.objects.filter(status='ACTIVE').count(), 'contracts_expiring_soon': ServiceContract.objects.filter( end_date__lte=today + timedelta(days=30), status='active' diff --git a/hospital_management/__pycache__/settings.cpython-312.pyc b/hospital_management/__pycache__/settings.cpython-312.pyc index 0eb9cf5a..7be632df 100644 Binary files a/hospital_management/__pycache__/settings.cpython-312.pyc and b/hospital_management/__pycache__/settings.cpython-312.pyc differ diff --git a/hospital_management/__pycache__/urls.cpython-312.pyc b/hospital_management/__pycache__/urls.cpython-312.pyc index bd65d38e..94113154 100644 Binary files a/hospital_management/__pycache__/urls.cpython-312.pyc and b/hospital_management/__pycache__/urls.cpython-312.pyc differ diff --git a/hospital_management/__pycache__/views.cpython-312.pyc b/hospital_management/__pycache__/views.cpython-312.pyc index 99b2869d..5473a672 100644 Binary files a/hospital_management/__pycache__/views.cpython-312.pyc and b/hospital_management/__pycache__/views.cpython-312.pyc differ diff --git a/hospital_management/settings.py b/hospital_management/settings.py index 581c15e0..909f0ae8 100644 --- a/hospital_management/settings.py +++ b/hospital_management/settings.py @@ -45,6 +45,7 @@ THIRD_PARTY_APPS = [ 'rest_framework', 'corsheaders', 'django_extensions', + 'webpack_loader', 'allauth', 'viewflow', 'viewflow.workflow', @@ -85,7 +86,7 @@ MIDDLEWARE = [ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'core.middleware.TenantMiddleware', + # 'core.middleware.TenantMiddleware', ] ROOT_URLCONF = 'hospital_management.urls' @@ -181,7 +182,7 @@ AUTH_USER_MODEL = 'accounts.User' # ACCOUNT_SIGNUP_FIELDS = ['email*', 'password1*', 'password2*'] # ACCOUNT_UNIQUE_EMAIL = True # -LOGOUT_REDIRECT_URL = 'home' +# LOGOUT_REDIRECT_URL = 'login' # ACCOUNT_SIGNUP_REDIRECT_URL = '/' # ACCOUNT_USER_MODEL_USERNAME_FIELD = 'email' # ACCOUNT_EMAIL_VERIFICATION = "mandatory" @@ -317,7 +318,19 @@ EMAIL_HOST_USER = config('EMAIL_HOST_USER', default='') EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='') DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default='noreply@hospital.com') +# Webpack Loader Configuration +WEBPACK_LOADER = { + 'DEFAULT': { + 'CACHE': not DEBUG, + 'BUNDLE_DIR_NAME': 'dist/', # must end with slash + 'STATS_FILE': os.path.join(BASE_DIR, 'webpack-stats.json'), + 'POLL_INTERVAL': 0.1, + 'TIMEOUT': None, + 'IGNORE': [r'.+\.hot-update.js', r'.+\.map'], + 'LOADER_CLASS': 'webpack_loader.loader.WebpackLoader', + } +} + # Create logs directory os.makedirs(BASE_DIR / 'logs', exist_ok=True) os.makedirs(DICOM_SETTINGS['STORAGE_PATH'], exist_ok=True) - diff --git a/hospital_management/urls.py b/hospital_management/urls.py index c9437a41..54f072ce 100644 --- a/hospital_management/urls.py +++ b/hospital_management/urls.py @@ -33,6 +33,7 @@ urlpatterns = [ ] urlpatterns += i18n_patterns( path("switch_language/", switch_language, name="switch_language"), + # path('', home, name='home'), path('accounts/', include('allauth.urls')), path('admin/', admin.site.urls), path('', include('core.urls')), diff --git a/hr/__pycache__/forms.cpython-312.pyc b/hr/__pycache__/forms.cpython-312.pyc index 21dc39b4..e6c382ad 100644 Binary files a/hr/__pycache__/forms.cpython-312.pyc and b/hr/__pycache__/forms.cpython-312.pyc differ diff --git a/hr/__pycache__/models.cpython-312.pyc b/hr/__pycache__/models.cpython-312.pyc index 2c77eca8..cec09522 100644 Binary files a/hr/__pycache__/models.cpython-312.pyc and b/hr/__pycache__/models.cpython-312.pyc differ diff --git a/hr/__pycache__/urls.cpython-312.pyc b/hr/__pycache__/urls.cpython-312.pyc index 6e136fe3..f61b9100 100644 Binary files a/hr/__pycache__/urls.cpython-312.pyc and b/hr/__pycache__/urls.cpython-312.pyc differ diff --git a/hr/__pycache__/views.cpython-312.pyc b/hr/__pycache__/views.cpython-312.pyc index 151e6d9a..49fedc93 100644 Binary files a/hr/__pycache__/views.cpython-312.pyc and b/hr/__pycache__/views.cpython-312.pyc differ diff --git a/hr/forms.py b/hr/forms.py index 89cf6807..5be22434 100644 --- a/hr/forms.py +++ b/hr/forms.py @@ -10,7 +10,9 @@ from decimal import Decimal from .models import ( Employee, Department, Schedule, ScheduleAssignment, - TimeEntry, PerformanceReview, TrainingRecord + TimeEntry, PerformanceReview, TrainingRecord, TrainingPrograms, + TrainingSession, ProgramModule, ProgramPrerequisite, + TrainingAttendance, TrainingAssessment, TrainingCertificates ) @@ -261,6 +263,470 @@ class EmployeeForm(forms.ModelForm): return cleaned_data +# =========================== +# Training Program Forms +# =========================== + +class TrainingProgramForm(forms.ModelForm): + """ + Form for creating and updating training programs. + """ + + class Meta: + model = TrainingPrograms + fields = [ + 'name', 'description', 'program_type', 'program_provider', + 'instructor', 'start_date', 'end_date', 'duration_hours', + 'cost', 'is_certified', 'validity_days', 'notify_before_days' + ] + widgets = { + 'name': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Training program name' + }), + 'description': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 3, + 'placeholder': 'Program description' + }), + 'program_type': forms.Select(attrs={'class': 'form-control'}), + 'program_provider': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Training provider/organization' + }), + 'instructor': forms.Select(attrs={'class': 'form-control'}), + 'start_date': forms.DateInput(attrs={ + 'class': 'form-control', + 'type': 'date' + }), + 'end_date': forms.DateInput(attrs={ + 'class': 'form-control', + 'type': 'date' + }), + 'duration_hours': forms.NumberInput(attrs={ + 'class': 'form-control', + 'step': '0.5', + 'min': '0' + }), + 'cost': forms.NumberInput(attrs={ + 'class': 'form-control', + 'step': '0.01', + 'min': '0' + }), + 'is_certified': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + 'validity_days': forms.NumberInput(attrs={ + 'class': 'form-control', + 'min': '1' + }), + 'notify_before_days': forms.NumberInput(attrs={ + 'class': 'form-control', + 'min': '1' + }), + } + help_texts = { + 'program_type': 'Type of training program', + 'duration_hours': 'Program duration in hours', + 'cost': 'Program cost per participant', + 'is_certified': 'Check if this program provides certification', + 'validity_days': 'Certificate validity period in days', + 'notify_before_days': 'Days before expiry to send notification', + } + + def __init__(self, *args, **kwargs): + user = kwargs.pop('user', None) + super().__init__(*args, **kwargs) + + if user and hasattr(user, 'tenant'): + # Filter instructor by tenant + self.fields['instructor'].queryset = Employee.objects.filter( + tenant=user.tenant, + employment_status='ACTIVE' + ).order_by('last_name', 'first_name') + + def clean(self): + cleaned_data = super().clean() + start_date = cleaned_data.get('start_date') + end_date = cleaned_data.get('end_date') + is_certified = cleaned_data.get('is_certified') + validity_days = cleaned_data.get('validity_days') + + if start_date and end_date: + if end_date < start_date: + self.add_error('end_date', 'End date cannot be before start date.') + + if is_certified and not validity_days: + self.add_error('validity_days', 'Validity period is required for certified programs.') + + return cleaned_data + + +class TrainingSessionForm(forms.ModelForm): + """ + Form for creating and updating training sessions. + """ + + class Meta: + model = TrainingSession + fields = [ + 'program', 'title', 'instructor', 'delivery_method', + 'start_at', 'end_at', 'location', 'capacity', + 'cost_override', 'hours_override' + ] + widgets = { + 'program': forms.Select(attrs={'class': 'form-control'}), + 'title': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Session title (optional)' + }), + 'instructor': forms.Select(attrs={'class': 'form-control'}), + 'delivery_method': forms.Select(attrs={'class': 'form-control'}), + 'start_at': forms.DateTimeInput(attrs={ + 'class': 'form-control', + 'type': 'datetime-local' + }), + 'end_at': forms.DateTimeInput(attrs={ + 'class': 'form-control', + 'type': 'datetime-local' + }), + 'location': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Training location' + }), + 'capacity': forms.NumberInput(attrs={ + 'class': 'form-control', + 'min': '1' + }), + 'cost_override': forms.NumberInput(attrs={ + 'class': 'form-control', + 'step': '0.01', + 'min': '0' + }), + 'hours_override': forms.NumberInput(attrs={ + 'class': 'form-control', + 'step': '0.5', + 'min': '0' + }), + } + help_texts = { + 'title': 'Optional session title (defaults to program name)', + 'delivery_method': 'How the training will be delivered', + 'capacity': 'Maximum number of participants', + 'cost_override': 'Override program cost for this session', + 'hours_override': 'Override program hours for this session', + } + + def __init__(self, *args, **kwargs): + user = kwargs.pop('user', None) + super().__init__(*args, **kwargs) + + if user and hasattr(user, 'tenant'): + # Filter program and instructor by tenant + self.fields['program'].queryset = TrainingPrograms.objects.filter( + tenant=user.tenant + ).order_by('name') + + self.fields['instructor'].queryset = Employee.objects.filter( + tenant=user.tenant, + employment_status='ACTIVE' + ).order_by('last_name', 'first_name') + + def clean(self): + cleaned_data = super().clean() + start_at = cleaned_data.get('start_at') + end_at = cleaned_data.get('end_at') + + if start_at and end_at: + if end_at <= start_at: + self.add_error('end_at', 'End time must be after start time.') + + return cleaned_data + + +class ProgramModuleForm(forms.ModelForm): + """ + Form for creating and updating program modules. + """ + + class Meta: + model = ProgramModule + fields = ['program', 'title', 'order', 'hours'] + widgets = { + 'program': forms.Select(attrs={'class': 'form-control'}), + 'title': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Module title' + }), + 'order': forms.NumberInput(attrs={ + 'class': 'form-control', + 'min': '1' + }), + 'hours': forms.NumberInput(attrs={ + 'class': 'form-control', + 'step': '0.5', + 'min': '0' + }), + } + help_texts = { + 'order': 'Module order within the program', + 'hours': 'Module duration in hours', + } + + def __init__(self, *args, **kwargs): + user = kwargs.pop('user', None) + super().__init__(*args, **kwargs) + + if user and hasattr(user, 'tenant'): + # Filter program by tenant + self.fields['program'].queryset = TrainingPrograms.objects.filter( + tenant=user.tenant + ).order_by('name') + + +class TrainingAttendanceForm(forms.ModelForm): + """ + Form for marking training attendance. + """ + + class Meta: + model = TrainingAttendance + fields = ['enrollment', 'checked_in_at', 'checked_out_at', 'status', 'notes'] + widgets = { + 'enrollment': forms.Select(attrs={'class': 'form-control'}), + 'checked_in_at': forms.DateTimeInput(attrs={ + 'class': 'form-control', + 'type': 'datetime-local' + }), + 'checked_out_at': forms.DateTimeInput(attrs={ + 'class': 'form-control', + 'type': 'datetime-local' + }), + 'status': forms.Select(attrs={'class': 'form-control'}), + 'notes': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Attendance notes' + }), + } + help_texts = { + 'status': 'Attendance status', + 'notes': 'Additional notes about attendance', + } + + def clean(self): + cleaned_data = super().clean() + checked_in_at = cleaned_data.get('checked_in_at') + checked_out_at = cleaned_data.get('checked_out_at') + status = cleaned_data.get('status') + + if status in ['PRESENT', 'LATE'] and not checked_in_at: + self.add_error('checked_in_at', 'Check-in time is required for present/late status.') + + if checked_in_at and checked_out_at: + if checked_out_at <= checked_in_at: + self.add_error('checked_out_at', 'Check-out time must be after check-in time.') + + return cleaned_data + + +class TrainingAssessmentForm(forms.ModelForm): + """ + Form for creating and updating training assessments. + """ + + class Meta: + model = TrainingAssessment + fields = ['enrollment', 'name', 'max_score', 'score', 'passed', 'taken_at', 'notes'] + widgets = { + 'enrollment': forms.Select(attrs={'class': 'form-control'}), + 'name': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Assessment name' + }), + 'max_score': forms.NumberInput(attrs={ + 'class': 'form-control', + 'step': '0.01', + 'min': '0' + }), + 'score': forms.NumberInput(attrs={ + 'class': 'form-control', + 'step': '0.01', + 'min': '0' + }), + 'passed': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + 'taken_at': forms.DateTimeInput(attrs={ + 'class': 'form-control', + 'type': 'datetime-local' + }), + 'notes': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 3, + 'placeholder': 'Assessment notes' + }), + } + help_texts = { + 'max_score': 'Maximum possible score', + 'score': 'Actual score achieved', + 'passed': 'Check if assessment was passed', + } + + def clean(self): + cleaned_data = super().clean() + max_score = cleaned_data.get('max_score') + score = cleaned_data.get('score') + + if max_score and score and score > max_score: + self.add_error('score', 'Score cannot exceed maximum score.') + + return cleaned_data + + +class TrainingCertificateForm(forms.ModelForm): + """ + Form for creating and updating training certificates. + """ + + class Meta: + model = TrainingCertificates + fields = [ + 'program', 'employee', 'enrollment', 'certificate_name', + 'certificate_number', 'certification_body', 'expiry_date', + 'signed_by' + ] + widgets = { + 'program': forms.Select(attrs={'class': 'form-control'}), + 'employee': forms.Select(attrs={'class': 'form-control'}), + 'enrollment': forms.Select(attrs={'class': 'form-control'}), + 'certificate_name': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Certificate name' + }), + 'certificate_number': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Certificate number' + }), + 'certification_body': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Issuing organization' + }), + 'expiry_date': forms.DateInput(attrs={ + 'class': 'form-control', + 'type': 'date' + }), + 'signed_by': forms.Select(attrs={'class': 'form-control'}), + } + help_texts = { + 'certificate_number': 'Unique certificate identifier', + 'certification_body': 'Organization that issued the certificate', + 'expiry_date': 'Certificate expiration date', + 'signed_by': 'Authority who signed the certificate', + } + + def __init__(self, *args, **kwargs): + user = kwargs.pop('user', None) + super().__init__(*args, **kwargs) + + if user and hasattr(user, 'tenant'): + # Filter by tenant + self.fields['program'].queryset = TrainingPrograms.objects.filter( + tenant=user.tenant, + is_certified=True + ).order_by('name') + + self.fields['employee'].queryset = Employee.objects.filter( + tenant=user.tenant, + employment_status='ACTIVE' + ).order_by('last_name', 'first_name') + + self.fields['enrollment'].queryset = TrainingRecord.objects.filter( + employee__tenant=user.tenant, + status='COMPLETED', + passed=True + ).order_by('-completion_date') + + self.fields['signed_by'].queryset = Employee.objects.filter( + tenant=user.tenant, + employment_status='ACTIVE' + ).order_by('last_name', 'first_name') + + def clean(self): + cleaned_data = super().clean() + enrollment = cleaned_data.get('enrollment') + program = cleaned_data.get('program') + employee = cleaned_data.get('employee') + + if enrollment and program and enrollment.program != program: + self.add_error('enrollment', 'Selected enrollment does not match the program.') + + if enrollment and employee and enrollment.employee != employee: + self.add_error('enrollment', 'Selected enrollment does not match the employee.') + + return cleaned_data + + +# =========================== +# Search and Filter Forms +# =========================== + +class TrainingSearchForm(forms.Form): + """ + Form for searching and filtering training records. + """ + search = forms.CharField( + required=False, + widget=forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Search training records...' + }) + ) + employee = forms.ModelChoiceField( + queryset=Employee.objects.none(), + required=False, + widget=forms.Select(attrs={'class': 'form-control'}) + ) + program = forms.ModelChoiceField( + queryset=TrainingPrograms.objects.none(), + required=False, + widget=forms.Select(attrs={'class': 'form-control'}) + ) + status = forms.ChoiceField( + choices=[('', 'All Statuses')] + list(TrainingRecord.TrainingStatus.choices), + required=False, + widget=forms.Select(attrs={'class': 'form-control'}) + ) + program_type = forms.ChoiceField( + choices=[('', 'All Types')] + list(TrainingPrograms.TrainingType.choices), + required=False, + widget=forms.Select(attrs={'class': 'form-control'}) + ) + start_date = forms.DateField( + required=False, + widget=forms.DateInput(attrs={ + 'class': 'form-control', + 'type': 'date' + }) + ) + end_date = forms.DateField( + required=False, + widget=forms.DateInput(attrs={ + 'class': 'form-control', + 'type': 'date' + }) + ) + + def __init__(self, *args, **kwargs): + tenant = kwargs.pop('tenant', None) + super().__init__(*args, **kwargs) + + if tenant: + self.fields['employee'].queryset = Employee.objects.filter( + tenant=tenant, + employment_status='ACTIVE' + ).order_by('last_name', 'first_name') + + self.fields['program'].queryset = TrainingPrograms.objects.filter( + tenant=tenant + ).order_by('name') + + class DepartmentForm(forms.ModelForm): """ Form for creating and updating departments. @@ -788,50 +1254,28 @@ class TrainingRecordForm(forms.ModelForm): class Meta: model = TrainingRecord - fields = '__all__' + fields = [ + 'employee', 'program', 'session', 'started_at', 'completion_date', + 'status', 'credits_earned', 'score', 'passed', 'notes', 'cost_paid' + ] widgets = { 'employee': forms.Select(attrs={'class': 'form-control'}), - 'training_name': forms.TextInput(attrs={ + 'program': forms.Select(attrs={'class': 'form-control'}), + 'session': forms.Select(attrs={'class': 'form-control'}), + 'started_at': forms.DateTimeInput(attrs={ 'class': 'form-control', - 'placeholder': 'Training program name' - }), - 'training_description': forms.Textarea(attrs={ - 'class': 'form-control', - 'rows': 3, - 'placeholder': 'Training description' - }), - 'training_type': forms.Select(attrs={'class': 'form-control'}), - 'training_provider': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Training provider/organization' - }), - 'instructor': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Instructor name' - }), - 'training_date': forms.DateInput(attrs={ - 'class': 'form-control', - 'type': 'date' + 'type': 'datetime-local' }), 'completion_date': forms.DateInput(attrs={ 'class': 'form-control', 'type': 'date' }), - 'expiry_date': forms.DateInput(attrs={ - 'class': 'form-control', - 'type': 'date' - }), - 'duration_hours': forms.NumberInput(attrs={ - 'class': 'form-control', - 'step': '0.5', - 'min': '0' - }), + 'status': forms.Select(attrs={'class': 'form-control'}), 'credits_earned': forms.NumberInput(attrs={ 'class': 'form-control', 'step': '0.1', 'min': '0' }), - 'status': forms.Select(attrs={'class': 'form-control'}), 'score': forms.NumberInput(attrs={ 'class': 'form-control', 'step': '0.01', @@ -839,15 +1283,7 @@ class TrainingRecordForm(forms.ModelForm): 'max': '100' }), 'passed': forms.CheckboxInput(attrs={'class': 'form-check-input'}), - 'certificate_number': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Certificate or credential number' - }), - 'certification_body': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Certification organization' - }), - 'training_cost': forms.NumberInput(attrs={ + 'cost_paid': forms.NumberInput(attrs={ 'class': 'form-control', 'step': '0.01', 'min': '0' @@ -859,11 +1295,11 @@ class TrainingRecordForm(forms.ModelForm): }), } help_texts = { - 'training_type': 'Type of training (mandatory, optional, certification, etc.)', - 'duration_hours': 'Training duration in hours', + 'program': 'Training program', + 'session': 'Specific training session (optional)', 'credits_earned': 'Continuing education credits earned', - 'training_cost': 'Training cost', - 'expiry_date': 'Certification expiry date (if applicable)', + 'cost_paid': 'Amount paid for training', + 'score': 'Score achieved (0-100)', } def __init__(self, *args, **kwargs): @@ -871,37 +1307,43 @@ class TrainingRecordForm(forms.ModelForm): super().__init__(*args, **kwargs) if user and hasattr(user, 'tenant'): - # Filter employee by tenant + # Filter by tenant self.fields['employee'].queryset = Employee.objects.filter( tenant=user.tenant, employment_status='ACTIVE' ).order_by('last_name', 'first_name') + + self.fields['program'].queryset = TrainingPrograms.objects.filter( + tenant=user.tenant + ).order_by('name') + + self.fields['session'].queryset = TrainingSession.objects.filter( + program__tenant=user.tenant + ).order_by('-start_at') + + # Make session optional + self.fields['session'].required = False def clean(self): cleaned_data = super().clean() - training_date = cleaned_data.get('training_date') + started_at = cleaned_data.get('started_at') completion_date = cleaned_data.get('completion_date') - expiry_date = cleaned_data.get('expiry_date') status = cleaned_data.get('status') passed = cleaned_data.get('passed') - certificate_number = cleaned_data.get('certificate_number') + session = cleaned_data.get('session') + program = cleaned_data.get('program') - if completion_date and training_date: - if completion_date < training_date: - self.add_error('completion_date', 'Completion date cannot be before training date.') - - if expiry_date and completion_date: - if expiry_date <= completion_date: - self.add_error('expiry_date', 'Expiry date must be after completion date.') + if completion_date and started_at: + if completion_date < started_at.date(): + self.add_error('completion_date', 'Completion date cannot be before start date.') if status == 'COMPLETED' and not completion_date: self.add_error('completion_date', 'Completion date is required for completed training.') - if passed and status not in ['COMPLETED', 'PASSED']: - self.add_error('passed', 'Training must be completed or passed to mark as passed.') + if passed and status not in ['COMPLETED']: + self.add_error('passed', 'Training must be completed to mark as passed.') - if certificate_number and not passed: - self.add_error('certificate_number', 'Certificate number can only be provided for passed training.') + if session and program and session.program != program: + self.add_error('session', 'Selected session does not belong to the selected program.') return cleaned_data - diff --git a/hr/migrations/0001_initial.py b/hr/migrations/0001_initial.py index 691fa4bb..6bd0996d 100644 --- a/hr/migrations/0001_initial.py +++ b/hr/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-09-19 10:58 +# Generated by Django 5.2.6 on 2025-09-26 18:33 import django.core.validators import django.db.models.deletion @@ -375,6 +375,7 @@ class Migration(migrations.Migration): ("LAB_TECH", "Laboratory Technician"), ("RADIOLOGIST", "Radiologist"), ("RAD_TECH", "Radiology Technician"), + ("RAD_SUPERVISOR", "Radiology Supervisor"), ("THERAPIST", "Therapist"), ("SOCIAL_WORKER", "Social Worker"), ("CASE_MANAGER", "Case Manager"), @@ -1277,7 +1278,7 @@ class Migration(migrations.Migration): null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="created_training_programs", - to="hr.employee", + to=settings.AUTH_USER_MODEL, ), ), ( @@ -1390,6 +1391,7 @@ class Migration(migrations.Migration): ("enrolled_at", models.DateTimeField(auto_now_add=True)), ("started_at", models.DateTimeField(blank=True, null=True)), ("completion_date", models.DateField(blank=True, null=True)), + ("expiry_date", models.DateField(blank=True, null=True)), ( "status", models.CharField( @@ -1502,7 +1504,7 @@ class Migration(migrations.Migration): null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="created_training_certificates", - to="hr.employee", + to=settings.AUTH_USER_MODEL, ), ), ( @@ -1520,7 +1522,7 @@ class Migration(migrations.Migration): null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="signed_training_certificates", - to="hr.employee", + to=settings.AUTH_USER_MODEL, ), ), ( @@ -1691,7 +1693,7 @@ class Migration(migrations.Migration): null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="created_training_sessions", - to="hr.employee", + to=settings.AUTH_USER_MODEL, ), ), ( diff --git a/hr/migrations/__pycache__/0001_initial.cpython-312.pyc b/hr/migrations/__pycache__/0001_initial.cpython-312.pyc index 046685ad..a2ec4e8d 100644 Binary files a/hr/migrations/__pycache__/0001_initial.cpython-312.pyc and b/hr/migrations/__pycache__/0001_initial.cpython-312.pyc differ diff --git a/hr/models.py b/hr/models.py index 130e4711..76a25409 100644 --- a/hr/models.py +++ b/hr/models.py @@ -80,6 +80,7 @@ class Employee(models.Model): LAB_TECH = 'LAB_TECH', 'Laboratory Technician' RADIOLOGIST = 'RADIOLOGIST', 'Radiologist' RAD_TECH = 'RAD_TECH', 'Radiology Technician' + RAD_SUPERVISOR = 'RAD_SUPERVISOR', 'Radiology Supervisor' THERAPIST = 'THERAPIST', 'Therapist' SOCIAL_WORKER = 'SOCIAL_WORKER', 'Social Worker' CASE_MANAGER = 'CASE_MANAGER', 'Case Manager' @@ -1175,7 +1176,7 @@ class TrainingPrograms(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) created_by = models.ForeignKey( - 'hr.Employee', on_delete=models.SET_NULL, + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='created_training_programs' ) @@ -1265,7 +1266,7 @@ class TrainingSession(models.Model): cost_override = models.DecimalField(max_digits=10, decimal_places=2,blank=True, null=True) hours_override = models.DecimalField(max_digits=5, decimal_places=2,blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True) - created_by = models.ForeignKey('hr.Employee', on_delete=models.SET_NULL,null=True, blank=True, related_name='created_training_sessions') + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,null=True, blank=True, related_name='created_training_sessions') class Meta: db_table = 'hr_training_session' @@ -1299,6 +1300,7 @@ class TrainingRecord(models.Model): enrolled_at = models.DateTimeField(auto_now_add=True) started_at = models.DateTimeField(blank=True, null=True) completion_date = models.DateField(blank=True, null=True) + expiry_date = models.DateField(blank=True, null=True) status = models.CharField(max_length=20, choices=TrainingStatus.choices, default=TrainingStatus.SCHEDULED) credits_earned = models.DecimalField(max_digits=5, decimal_places=2,default=Decimal('0.00')) score = models.DecimalField(max_digits=5, decimal_places=2, blank=True, null=True) @@ -1345,6 +1347,37 @@ class TrainingRecord(models.Model): def eligible_for_certificate(self): return self.status == 'COMPLETED' and self.passed and self.program.is_certified + @property + def completion_percentage(self): + """ + Calculate completion percentage based on training status and progress. + """ + if self.status == 'COMPLETED': + return 100 + elif self.status == 'IN_PROGRESS': + # If we have a session with start/end dates, calculate based on time elapsed + if self.session and self.session.start_at and self.session.end_at: + now = timezone.now() + total_duration = (self.session.end_at - self.session.start_at).total_seconds() + if now >= self.session.end_at: + return 100 + elif now <= self.session.start_at: + return 0 + else: + elapsed = (now - self.session.start_at).total_seconds() + return min(100, max(0, (elapsed / total_duration) * 100)) + else: + # Default to 50% for in-progress without specific timing + return 50 + elif self.status == 'SCHEDULED': + return 0 + elif self.status in ['CANCELLED', 'NO_SHOW', 'FAILED']: + return 0 + elif self.status == 'WAITLISTED': + return 0 + else: + return 0 + class TrainingAttendance(models.Model): """ @@ -1410,11 +1443,11 @@ class TrainingCertificates(models.Model): updated_at = models.DateTimeField(auto_now=True) created_by = models.ForeignKey( - 'hr.Employee', on_delete=models.SET_NULL, + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='created_training_certificates' ) signed_by = models.ForeignKey( - 'hr.Employee', on_delete=models.SET_NULL, + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='signed_training_certificates' ) @@ -1444,4 +1477,3 @@ class TrainingCertificates(models.Model): if program.is_certified and program.validity_days: return issued_on + timedelta(days=program.validity_days) return None - diff --git a/hr/templates/hr/employees/employee_detail.html b/hr/templates/hr/employees/employee_detail.html index 41043fc6..4f3b7888 100644 --- a/hr/templates/hr/employees/employee_detail.html +++ b/hr/templates/hr/employees/employee_detail.html @@ -71,7 +71,7 @@
-
+
Employee Information
@@ -139,7 +139,7 @@
-
+
Contact Information
@@ -200,7 +200,7 @@
-
+
Emergency Contact
@@ -235,25 +235,26 @@
-
+
Recent Activity
+
Employee profile updated

Contact information updated

- {{ employee.updated_at|timesince }} ago + {{ employee.updated_at|timesince }} ago
Employee hired
-

Joined {{ employee.department.name|default:"the organization" }}

+

Joined {{ employee.department.name }}

{{ employee.hire_date|timesince }} ago
diff --git a/hr/templates/hr/training/analytics.html b/hr/templates/hr/training/analytics.html new file mode 100644 index 00000000..d4cedc3f --- /dev/null +++ b/hr/templates/hr/training/analytics.html @@ -0,0 +1,273 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Training Analytics - HR Management{% endblock %} + +{% block css %} +{% endblock %} + +{% block content %} +
+
+
+ +
+
+

Training Analytics

+

Comprehensive training performance metrics and insights

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

{{ total_programs }}

+

Total Programs

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

{{ total_sessions }}

+

Total Sessions

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

{{ total_enrollments }}

+

Total Enrollments

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

{{ total_certificates }}

+

Certificates Issued

+
+
+ +
+
+
+
+
+
+ + +
+
+
+
+
Training Completion by Program Type
+
+
+
+ + + + + + + + + + + {% for stat in program_stats %} + + + + + + + {% empty %} + + + + {% endfor %} + +
Program TypeTotal ProgramsTotal EnrollmentsCompletion Rate
{{ stat.type }}{{ stat.total_programs }}{{ stat.total_enrollments }} +
+
+ {{ stat.completion_rate }}% +
+
+
No program statistics available
+
+
+
+
+
+ + +
+
+
+
+
Department Training Compliance
+
+
+
+ + + + + + + + + + {% for dept in department_compliance %} + + + + + + {% empty %} + + + + {% endfor %} + +
DepartmentTotal EmployeesCompliance Rate
{{ dept.department }}{{ dept.employees }} +
+
+ {{ dept.compliance_rate }}% +
+
+
No department data available
+
+
+
+
+
+ + +
+
+
+
+
Upcoming Certificate Expirations (Next 30 Days)
+
+
+ {% if expiring_certificates %} +
+ + + + + + + + + + + + {% for cert in expiring_certificates %} + + + + + + + + {% endfor %} + +
EmployeeCertificateProgramExpiry DateDays Remaining
+ + {{ cert.employee.get_full_name }} + + {{ cert.certificate_name }} + + {{ cert.program.name }} + + {{ cert.expiry_date|date:"M d, Y" }} + {% with days_remaining=cert.expiry_date|timeuntil %} + {% if "day" in days_remaining %} + {% if "1 day" in days_remaining %} + 1 day + {% else %} + {% with days=days_remaining|cut:" days"|cut:" day" %} + {% if days|add:0 <= 7 %} + {{ days }} days + {% elif days|add:0 <= 30 %} + {{ days }} days + {% else %} + {{ days }} days + {% endif %} + {% endwith %} + {% endif %} + {% else %} + Expired + {% endif %} + {% endwith %} +
+
+ {% else %} +
+ +
No Certificates Expiring Soon
+

All certificates are current or have no expiry date.

+
+ {% endif %} +
+
+
+
+
+
+
+{% endblock %} + +{% block js %} + +{% endblock %} diff --git a/hr/templates/hr/training/archive_record.html b/hr/templates/hr/training/archive_record.html new file mode 100644 index 00000000..2ef180b7 --- /dev/null +++ b/hr/templates/hr/training/archive_record.html @@ -0,0 +1,218 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Archive Training Record | HR Management{% endblock %} + +{% block css %} + +{% endblock %} + +{% block content %} +
+
+
+ +
+
+ +

Archive Training Record

+

Archive training record for {{ record.employee.get_full_name }}

+
+
+
+
+ +
+
+
+ +
+
Training Record Details
+
+
+ Employee:
+ {{ record.employee.get_full_name }} +
+
+ Program:
+ {{ record.program.name }} +
+
+
+
+ Status:
+ {{ record.get_status_display }} +
+
+ Enrolled:
+ {{ record.enrolled_at|date:"M d, Y" }} +
+
+ {% if record.completion_date %} +
+
+ Completed:
+ {{ record.completion_date|date:"M d, Y" }} +
+ {% if record.score %} +
+ Score:
+ {{ record.score }}% +
+ {% endif %} +
+ {% endif %} +
+ + +
+
+
+ +
+
+
Archive Training Record
+

+ Archiving this training record will mark it as cancelled and remove it from active training lists. + The record will be preserved for historical purposes but will no longer be considered active. +

+

+ Note: This action can be reversed by editing the record and changing its status. +

+
+
+
+ + +
+
+
Archive Confirmation
+
+
+
+ {% csrf_token %} + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + Cancel + + +
+
+
+
+ + +
+
+
+
What happens when a record is archived?
+
+
+
    +
  • The record status will be changed to "Cancelled"
  • +
  • The record will be removed from active training lists
  • +
  • Archive reason and date will be added to the record notes
  • +
  • The record will still be visible in detailed views for historical purposes
  • +
  • Any associated certificates will remain valid unless separately revoked
  • +
  • The action can be reversed by editing the record status
  • +
+
+
+
+
+
+
+
+{% endblock %} + +{% block js %} + +{% endblock %} diff --git a/hr/templates/hr/training/certificate_detail.html b/hr/templates/hr/training/certificate_detail.html new file mode 100644 index 00000000..2252bbaf --- /dev/null +++ b/hr/templates/hr/training/certificate_detail.html @@ -0,0 +1,291 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}{{ certificate.certificate_name }} - Training Certificate{% endblock %} + +{% block css %} +{% endblock %} + +{% block content %} +
+
+
+ +
+
+ +

{{ certificate.certificate_name }}

+

Training Certificate Details

+
+
+ {% if certificate.file %} + + Download Certificate + + {% endif %} +
+
+ +
+ +
+
+
+
Certificate Information
+
+
+
+
+
+
Certificate Name:
+
{{ certificate.certificate_name }}
+ + {% if certificate.certificate_number %} +
Certificate Number:
+
{{ certificate.certificate_number }}
+ {% endif %} + +
Employee:
+
+ + {{ certificate.employee.get_full_name }} + +
+ +
Department:
+
+ {% if certificate.employee.department %} + {{ certificate.employee.department.name }} + {% else %} + No Department + {% endif %} +
+
+
+
+
+
Program:
+
+ + {{ certificate.program.name }} + +
+ +
Program Type:
+
{{ certificate.program.get_program_type_display }}
+ +
Issued Date:
+
{{ certificate.issued_date|date:"F d, Y" }}
+ +
Expiry Date:
+
+ {% if certificate.expiry_date %} + {{ certificate.expiry_date|date:"F d, Y" }} + {% if certificate.is_expired %} +
Expired + {% elif certificate.days_to_expiry <= 30 %} +
Expires in {{ certificate.days_to_expiry }} days + {% endif %} + {% else %} + No Expiry + {% endif %} +
+
+
+
+ + {% if certificate.certification_body %} +
+
Certification Body
+

{{ certificate.certification_body }}

+
+ {% endif %} +
+
+ + +
+
+
Training Record
+
+
+
+
+
+
Enrollment Date:
+
{{ certificate.enrollment.enrolled_at|date:"M d, Y" }}
+ +
Completion Date:
+
+ {% if certificate.enrollment.completion_date %} + {{ certificate.enrollment.completion_date|date:"M d, Y" }} + {% else %} + Not completed + {% endif %} +
+ +
Status:
+
+ {% if certificate.enrollment.status == 'COMPLETED' %} + {{ certificate.enrollment.get_status_display }} + {% else %} + {{ certificate.enrollment.get_status_display }} + {% endif %} +
+
+
+
+
+ {% if certificate.enrollment.score %} +
Score:
+
{{ certificate.enrollment.score }}%
+ {% endif %} + +
Passed:
+
+ {% if certificate.enrollment.passed %} + Yes + {% else %} + No + {% endif %} +
+ +
Credits Earned:
+
{{ certificate.enrollment.credits_earned }} hours
+
+
+
+ + {% if certificate.enrollment.notes %} +
+
Notes
+

{{ certificate.enrollment.notes }}

+
+ {% endif %} +
+
+ + + {% if certificate.file %} +
+
+
Certificate File
+
+
+
+ +
{{ certificate.file.name|default:"Certificate File" }}
+

Click the download button above to view the full certificate.

+ + Open in New Tab + +
+
+
+ {% endif %} +
+ + +
+ +
+
+
Certificate Status
+
+
+ {% if certificate.is_expired %} +
+ +
Expired
+

+ Expired on {{ certificate.expiry_date|date:"M d, Y" }} +

+
+ {% elif certificate.days_to_expiry and certificate.days_to_expiry <= 30 %} +
+ +
Expiring Soon
+

+ Expires in {{ certificate.days_to_expiry }} days
+ on {{ certificate.expiry_date|date:"M d, Y" }} +

+
+ {% else %} +
+ +
Valid
+ {% if certificate.expiry_date %} +

+ Valid until {{ certificate.expiry_date|date:"M d, Y" }} +

+ {% else %} +

No expiration date

+ {% endif %} +
+ {% endif %} +
+
+ + + {% if certificate.created_by or certificate.signed_by %} +
+
+
Signatories
+
+
+ {% if certificate.created_by %} +
+
Created By
+

{{ certificate.created_by.get_full_name }}

+ {{ certificate.created_at|date:"M d, Y g:i A" }} +
+ {% endif %} + + {% if certificate.signed_by %} +
+
Signed By
+

{{ certificate.signed_by.get_full_name }}

+
+ {% endif %} +
+
+ {% endif %} + + +
+
+
Quick Actions
+
+ +
+
+
+
+
+
+{% endblock %} diff --git a/hr/templates/hr/training/certificate_list.html b/hr/templates/hr/training/certificate_list.html new file mode 100644 index 00000000..33cb7775 --- /dev/null +++ b/hr/templates/hr/training/certificate_list.html @@ -0,0 +1,242 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Training Certificates - HR Management{% endblock %} + +{% block css %} + + +{% endblock %} + +{% block content %} +
+
+
+ +
+
+

Training Certificates

+

Manage issued training certificates

+
+
+ + +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ + +
+
+ {% if certificates %} +
+ + + + + + + + + + + + + + {% for certificate in certificates %} + + + + + + + + + + {% endfor %} + +
CertificateEmployeeProgramIssued DateExpiry DateStatusActions
+
+ {{ certificate.certificate_name }} + {% if certificate.certificate_number %} +
{{ certificate.certificate_number }} + {% endif %} +
+
+ + {{ certificate.employee.get_full_name }} + + {% if certificate.employee.department %} +
{{ certificate.employee.department.name }} + {% endif %} +
+ + {{ certificate.program.name }} + +
{{ certificate.program.get_program_type_display }} +
{{ certificate.issued_date|date:"M d, Y" }} + {% if certificate.expiry_date %} + {{ certificate.expiry_date|date:"M d, Y" }} + {% if certificate.is_expired %} +
Expired + {% elif certificate.days_to_expiry <= 30 %} +
Expires in {{ certificate.days_to_expiry }} days + {% endif %} + {% else %} + No Expiry + {% endif %} +
+ {% if certificate.is_expired %} + + Expired + + {% elif certificate.days_to_expiry and certificate.days_to_expiry <= 30 %} + + Expiring Soon + + {% else %} + + Valid + + {% endif %} + +
+ + + + {% if certificate.file %} + + + + {% endif %} + + + +
+
+
+ + + {% if is_paginated %} + + {% endif %} + {% else %} +
+ +
No Training Certificates Found
+

Certificates will appear here when employees complete certified training programs.

+
+ {% endif %} +
+
+ + + {% if certificates %} +
+
+
+
+

{{ valid_count|default:0 }}

+ Valid Certificates +
+
+
+
+
+
+

{{ expiring_count|default:0 }}

+ Expiring Soon +
+
+
+
+
+
+

{{ expired_count|default:0 }}

+ Expired +
+
+
+
+
+
+

{{ certificates|length }}

+ Total Certificates +
+
+
+
+ {% endif %} +
+
+
+{% endblock %} diff --git a/hr/templates/hr/training/duplicate_record.html b/hr/templates/hr/training/duplicate_record.html new file mode 100644 index 00000000..b432d986 --- /dev/null +++ b/hr/templates/hr/training/duplicate_record.html @@ -0,0 +1,354 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Duplicate Training Record | HR Management{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+
+ +
+
+ +

Duplicate Training Record

+

Create a copy of this training record for another employee

+
+
+
+
+ +
+
+
+ +
+
Original Training Record
+
+
+ Employee:
+ {{ record.employee.get_full_name }} +
+
+ Program:
+ {{ record.program.name }} +
+
+
+
+ Status:
+ {{ record.get_status_display }} +
+
+ Enrolled:
+ {{ record.enrolled_at|date:"M d, Y" }} +
+
+ {% if record.completion_date %} +
+
+ Completed:
+ {{ record.completion_date|date:"M d, Y" }} +
+ {% if record.score %} +
+ Score:
+ {{ record.score }}% +
+ {% endif %} +
+ {% endif %} +
+ + +
+
+
+ +
+
+
Duplicate Training Record
+

+ This will create a new training record for the selected employee using the same program. + The new record will start with "Scheduled" status and can be assigned to a training session. +

+

+ Note: The original record will remain unchanged. This is useful for enrolling + multiple employees in the same training program. +

+
+
+
+ + +
+
+
Create Duplicate Record
+
+
+
+ {% csrf_token %} + + +
+ +

Choose the employee who will receive the duplicate training record.

+ + + +
+ + + {% for employee in employees %} +
+
+ {% if employee.profile_picture %} + {{ employee.get_full_name }} + {% else %} +
+ {{ employee.first_name|first }}{{ employee.last_name|first }} +
+ {% endif %} +
+
{{ employee.get_full_name }}
+

+ ID: {{ employee.employee_id }} + {% if employee.department %} | {{ employee.department.name }}{% endif %} + {% if employee.job_title %} | {{ employee.job_title }}{% endif %} +

+
+ {% if employee.id == record.employee.id %} + Original + {% endif %} +
+
+ {% endfor %} +
+
+ + + {% if available_sessions %} +
+ +

You can assign the employee to a session now, or do it later.

+ +
+ + + {% for session in available_sessions %} +
+
+
+
{{ session.title|default:session.program.name }}
+

+ {{ session.start_at|date:"M d, Y H:i" }} - {{ session.end_at|date:"M d, Y H:i" }} +

+

+ Delivery: {{ session.get_delivery_method_display }} + {% if session.location %} + | Location: {{ session.location }} + {% endif %} +

+
+
+ {% if session.capacity %} + + {{ session.enrollments.count }}/{{ session.capacity }} enrolled + + {% endif %} +
+
+
+ {% endfor %} +
+
+ {% else %} +
+ + No upcoming sessions are currently available for this program. + You can create the duplicate record and assign a session later. +
+ {% endif %} + + +
+ + +
+ +
+ + +
+ +
+ + Cancel + + +
+
+
+
+ + +
+
+
+
What happens when you create a duplicate?
+
+
+
    +
  • A new training record will be created for the selected employee
  • +
  • The new record will have "Scheduled" status
  • +
  • The employee will be enrolled in the selected session (if chosen)
  • +
  • The original record will remain unchanged
  • +
  • The duplicate will reference the original record in its notes
  • +
  • Both records can be tracked separately in the system
  • +
+
+
+
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/hr/templates/hr/training/mark_complete.html b/hr/templates/hr/training/mark_complete.html new file mode 100644 index 00000000..3dcf766d --- /dev/null +++ b/hr/templates/hr/training/mark_complete.html @@ -0,0 +1,225 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Mark Training Complete - HR Management{% endblock %} + +{% block css %} +{% endblock %} + +{% block content %} +
+
+
+ +
+
+ +

Mark Training Complete

+

Complete training record for {{ record.employee.get_full_name }}

+
+
+ +
+
+ +
+
+
Training Information
+
+
+
+
+
+
Employee:
+
{{ record.employee.get_full_name }}
+ +
Department:
+
+ {% if record.employee.department %} + {{ record.employee.department.name }} + {% else %} + No Department + {% endif %} +
+ +
Program:
+
+ {% if record.program %} + {{ record.program.name }} + {% else %} + No Program + {% endif %} +
+
+
+
+
+
Session:
+
+ {% if record.session %} + {{ record.session.title|default:record.session.program.name }} + {% else %} + No Session + {% endif %} +
+ +
Enrolled Date:
+
{{ record.enrolled_at|date:"M d, Y" }}
+ +
Current Status:
+
+ {{ record.get_status_display }} +
+
+
+
+
+
+ + +
+
+
Mark as Complete
+
+
+
+ {% csrf_token %} + +
+
+
+ +
+ + % +
+
Enter the final score if applicable (0-100%)
+
+
+
+
+ + +
Training will be marked as completed today
+
+
+
+ +
+ + +
Optional notes about the completion
+
+ + {% if can_issue_certificate %} +
+ + Certificate Eligible: This training program is certified. + A certificate will be automatically issued upon completion if the employee passes. +
+ {% endif %} + +
+ + Cancel + + +
+
+
+
+
+ + +
+ +
+
+
Current Status
+
+
+
+ +
{{ record.get_status_display }}
+

+ Enrolled on {{ record.enrolled_at|date:"M d, Y" }} +

+
+
+
+ + + {% if record.program %} +
+
+
Program Details
+
+
+
+
Type:
+
{{ record.program.get_program_type_display }}
+ +
Duration:
+
{{ record.program.duration_hours }} hours
+ + {% if record.program.is_certified %} +
Certified:
+
+ + Yes + +
+ {% endif %} +
+
+
+ {% endif %} + + +
+
+
Completion Guidelines
+
+
+
    +
  • + + Verify training attendance +
  • +
  • + + Confirm learning objectives met +
  • +
  • + + Enter final score if applicable +
  • +
  • + + Add completion notes +
  • + {% if can_issue_certificate %} +
  • + + Certificate will be auto-issued +
  • + {% endif %} +
+
+
+
+
+
+
+
+{% endblock %} diff --git a/hr/templates/hr/training/mark_expired.html b/hr/templates/hr/training/mark_expired.html new file mode 100644 index 00000000..d837af35 --- /dev/null +++ b/hr/templates/hr/training/mark_expired.html @@ -0,0 +1,248 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Mark Training Record as Expired | HR Management{% endblock %} + +{% block css %} + +{% endblock %} + +{% block content %} +
+
+
+ +
+
+ +

Mark Training Record as Expired

+

Mark training record as expired for {{ record.employee.get_full_name }}

+
+
+
+
+ +
+
+
+ +
+
Training Record Details
+
+
+ Employee:
+ {{ record.employee.get_full_name }} +
+
+ Program:
+ {{ record.program.name }} +
+
+
+
+ Current Status:
+ {{ record.get_status_display }} +
+
+ Enrolled:
+ {{ record.enrolled_at|date:"M d, Y" }} +
+
+ {% if record.completion_date %} +
+
+ Completed:
+ {{ record.completion_date|date:"M d, Y" }} +
+ {% if record.expiry_date %} +
+ Expiry Date:
+ {{ record.expiry_date|date:"M d, Y" }} +
+ {% endif %} +
+ {% endif %} +
+ + +
+
+
+ +
+
+
Mark Training as Expired
+

+ This action will mark the training record as expired/invalid. This is typically used when: +

+
    +
  • The certification has expired and is no longer valid
  • +
  • The training content is outdated or superseded
  • +
  • Compliance requirements have changed
  • +
  • The employee needs to retake the training
  • +
+

+ Note: This action can be reversed by editing the record status. +

+
+
+
+ + +
+
+
Mark as Expired
+
+
+
+ {% csrf_token %} + +
+ + +
+ +
+ + +
The date when this training became expired/invalid
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + Cancel + + +
+
+
+
+ + +
+
+
+
What happens when a record is marked as expired?
+
+
+
    +
  • The record status will be changed to "Failed" (indicating expired/invalid)
  • +
  • Expiry reason and date will be added to the record notes
  • +
  • The record will appear in expired training reports
  • +
  • Any associated certificates may need to be revoked separately
  • +
  • The employee may need to retake the training
  • +
  • Compliance tracking will reflect the expired status
  • +
  • The action can be reversed by editing the record status
  • +
+
+
+
+
+
+
+
+{% endblock %} + +{% block js %} + +{% endblock %} diff --git a/hr/templates/hr/training/program_detail.html b/hr/templates/hr/training/program_detail.html new file mode 100644 index 00000000..9b694596 --- /dev/null +++ b/hr/templates/hr/training/program_detail.html @@ -0,0 +1,295 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}{{ program.name }} - Training Program{% endblock %} + +{% block css %} + + +{% endblock %} + +{% block content %} +
+
+
+ +
+
+ +

{{ program.name }}

+

{{ program.get_program_type_display }} Training Program

+
+ +
+ +
+ +
+
+
+
Program Information
+
+
+
+
+
+
Type:
+
+ {{ program.get_program_type_display }} +
+ +
Duration:
+
{{ program.duration_hours }} hours
+ +
Cost:
+
+ {% if program.cost %} + ${{ program.cost|floatformat:2 }} + {% else %} + Free + {% endif %} +
+ +
Certified:
+
+ {% if program.is_certified %} + + Yes + + {% if program.validity_days %} +
Valid for {{ program.validity_days }} days + {% endif %} + {% else %} + No + {% endif %} +
+
+
+
+
+
Provider:
+
{{ program.program_provider|default:"Internal" }}
+ +
Instructor:
+
+ {% if program.instructor %} + {{ program.instructor.get_full_name }} + {% else %} + TBD + {% endif %} +
+ + {% if program.start_date %} +
Start Date:
+
{{ program.start_date|date:"M d, Y" }}
+ {% endif %} + + {% if program.end_date %} +
End Date:
+
{{ program.end_date|date:"M d, Y" }}
+ {% endif %} +
+
+
+ + {% if program.description %} +
+
Description
+

{{ program.description }}

+
+ {% endif %} +
+
+ + + {% if modules %} +
+
+
Program Modules
+
+
+
+ {% for module in modules %} +
+
+ {{ module.order }}. {{ module.title }} + {% if module.hours %} +
{{ module.hours }} hours + {% endif %} +
+
+ {% endfor %} +
+
+
+ {% endif %} + + + {% if prerequisites %} +
+
+
Prerequisites
+
+
+
+ {% for prereq in prerequisites %} +
+ + {{ prereq.required_program.name }} + + {{ prereq.required_program.get_program_type_display }} +
+ {% endfor %} +
+
+
+ {% endif %} + + + {% if upcoming_sessions %} +
+
+
Upcoming Sessions
+
+
+
+ + + + + + + + + + + + + {% for session in upcoming_sessions %} + + + + + + + + + {% endfor %} + +
DateTimeLocationInstructorCapacityActions
{{ session.start_at|date:"M d, Y" }}{{ session.start_at|time:"g:i A" }} - {{ session.end_at|time:"g:i A" }}{{ session.location|default:"TBD" }} + {% if session.instructor %} + {{ session.instructor.get_full_name }} + {% else %} + TBD + {% endif %} + + {% if session.capacity %} + {{ session.capacity }} seats + {% else %} + Unlimited + {% endif %} + + View +
+
+
+
+ {% endif %} + + + {% if recent_completions %} +
+
+
Recent Completions
+
+
+
+ {% for completion in recent_completions %} +
+
+ {{ completion.employee.get_full_name }} +
+ Completed {{ completion.completion_date|date:"M d, Y" }} + {% if completion.score %} + - Score: {{ completion.score }}% + {% endif %} + +
+ {% if completion.passed %} + Passed + {% endif %} +
+ {% endfor %} +
+
+
+ {% endif %} +
+ + +
+
+
+
Program Statistics
+
+
+
+
+
+

{{ total_enrollments }}

+ Total Enrollments +
+
+
+

{{ completion_rate }}%

+ Completion Rate +
+
+
+
+ + +
+
+
Quick Actions
+
+
+
+ + Schedule New Session + + + View All Enrollments + + {% if program.is_certified %} + + View Certificates + + {% endif %} +
+
+
+
+
+
+
+
+{% endblock %} diff --git a/hr/templates/hr/training/program_form.html b/hr/templates/hr/training/program_form.html new file mode 100644 index 00000000..007f1ed9 --- /dev/null +++ b/hr/templates/hr/training/program_form.html @@ -0,0 +1,330 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %} + {% if object %}Edit Training Program{% else %}Create Training Program{% endif %} - HR Management +{% endblock %} + +{% block css %} +{% endblock %} + +{% block content %} +
+
+
+ +
+
+ +

+ {% if object %}Edit Training Program{% else %}Create Training Program{% endif %} +

+
+
+ +
+
+
+
+
+ {% csrf_token %} + + +
+
+
Basic Information
+
+ +
+
+ + {{ form.name }} + {% if form.name.errors %} +
+ {{ form.name.errors.0 }} +
+ {% endif %} +
+
+ +
+
+ + {{ form.program_type }} + {% if form.program_type.errors %} +
+ {{ form.program_type.errors.0 }} +
+ {% endif %} +
+
+ +
+
+ + {{ form.description }} + {% if form.description.errors %} +
+ {{ form.description.errors.0 }} +
+ {% endif %} +
+
+
+ + +
+
+
Provider & Instructor
+
+ +
+
+ + {{ form.program_provider }} + {% if form.program_provider.errors %} +
+ {{ form.program_provider.errors.0 }} +
+ {% endif %} +
+
+ +
+
+ + {{ form.instructor }} + {% if form.instructor.errors %} +
+ {{ form.instructor.errors.0 }} +
+ {% endif %} +
+
+
+ + +
+
+
Schedule & Duration
+
+ +
+
+ + {{ form.start_date }} + {% if form.start_date.errors %} +
+ {{ form.start_date.errors.0 }} +
+ {% endif %} +
+
+ +
+
+ + {{ form.end_date }} + {% if form.end_date.errors %} +
+ {{ form.end_date.errors.0 }} +
+ {% endif %} +
+
+ +
+
+ + {{ form.duration_hours }} + {% if form.duration_hours.errors %} +
+ {{ form.duration_hours.errors.0 }} +
+ {% endif %} +
+
+
+ + +
+
+
Cost & Certification
+
+ +
+
+ +
+ $ + {{ form.cost }} +
+ {% if form.cost.errors %} +
+ {{ form.cost.errors.0 }} +
+ {% endif %} +
+
+ +
+
+
+ {{ form.is_certified }} + +
+ {% if form.is_certified.errors %} +
+ {{ form.is_certified.errors.0 }} +
+ {% endif %} +
+
+
+ + + + + +
+
+
+
+ + Cancel + + +
+
+
+
+
+
+
+ + +
+
+
+
+ Help & Guidelines +
+
+
+
Program Types
+
    +
  • Orientation: New employee onboarding
  • +
  • Mandatory: Required compliance training
  • +
  • Continuing Education: Professional development
  • +
  • Certification: Professional certifications
  • +
  • Safety: Workplace safety training
  • +
+ +
Certification
+

+ Check "provides certification" if this program awards a certificate upon completion. + Set validity period for certificates that expire. +

+ +
Cost
+

+ Leave cost empty for free programs. Cost is per participant and can be overridden + at the session level. +

+
+
+
+
+
+
+
+{% endblock %} + +{% block js %} + +{% endblock %} diff --git a/hr/templates/hr/training/program_list.html b/hr/templates/hr/training/program_list.html new file mode 100644 index 00000000..09bbee0a --- /dev/null +++ b/hr/templates/hr/training/program_list.html @@ -0,0 +1,180 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Training Programs - HR Management{% endblock %} + +{% block css %} +{% endblock %} + +{% block content %} +
+
+
+ +
+
+

Training Programs

+

Manage training programs and curricula

+
+ +
+ + +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ + +
+
+ {% if programs %} +
+ + + + + + + + + + + + + + {% for program in programs %} + + + + + + + + + + {% endfor %} + +
Program NameTypeDurationCostCertifiedInstructorActions
+
+ {{ program.name }} + {% if program.description %} +
{{ program.description|truncatechars:60 }} + {% endif %} +
+
+ {{ program.get_program_type_display }} + {{ program.duration_hours }} hours + {% if program.cost %} + ${{ program.cost|floatformat:2 }} + {% else %} + Free + {% endif %} + + {% if program.is_certified %} + + Certified + + {% else %} + Non-Certified + {% endif %} + + {% if program.instructor %} + {{ program.instructor.get_full_name }} + {% else %} + TBD + {% endif %} + + +
+
+ + + {% if is_paginated %} + + {% endif %} + {% else %} +
+ +
No Training Programs Found
+

Create your first training program to get started.

+ + Create Program + +
+ {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/hr/templates/hr/training/renew_record.html b/hr/templates/hr/training/renew_record.html new file mode 100644 index 00000000..dd6afc78 --- /dev/null +++ b/hr/templates/hr/training/renew_record.html @@ -0,0 +1,268 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Renew Training Record | HR Management{% endblock %} + +{% block css %} + +{% endblock %} + +{% block content %} +
+
+
+ +
+
+ +

Renew Training Record

+

Create a renewal for {{ record.employee.get_full_name }}

+
+
+
+
+ +
+
+
+ +
+
Original Training Record
+
+
+ Employee:
+ {{ record.employee.get_full_name }} +
+
+ Program:
+ {{ record.program.name }} +
+
+
+
+ Original Completion:
+ {% if record.completion_date %} + {{ record.completion_date|date:"M d, Y" }} + {% else %} + Not completed + {% endif %} +
+
+ Expiry Date:
+ {% if record.expiry_date %} + {{ record.expiry_date|date:"M d, Y" }} + {% if record.expiry_date < today %} + Expired + {% endif %} + {% else %} + No expiry date + {% endif %} +
+
+
+ + +
+
+
+ +
+
+
Training Renewal
+

+ This will create a new training record for the same program. The employee will need to be + assigned to a new training session to complete the renewal. +

+

+ Note: The original record will remain unchanged for historical purposes. +

+
+
+
+ + +
+
+
Create Renewal Record
+
+
+
+ {% csrf_token %} + +
+ + +
+ +
+ + +
+ + + {% if available_sessions %} +
+ +

You can assign the employee to a session now, or do it later.

+ +
+ + + {% for session in available_sessions %} +
+
+
+
{{ session.title|default:session.program.name }}
+

+ {{ session.start_at|date:"M d, Y H:i" }} - {{ session.end_at|date:"M d, Y H:i" }} +

+

+ Delivery: {{ session.get_delivery_method_display }} + {% if session.location %} + | Location: {{ session.location }} + {% endif %} +

+
+
+ {% if session.capacity %} + + {{ session.enrollments.count }}/{{ session.capacity }} enrolled + + {% endif %} +
+
+
+ {% endfor %} +
+
+ {% else %} +
+ + No upcoming sessions are currently available for this program. + You can create the renewal record and assign a session later. +
+ {% endif %} + +
+ + +
+ +
+ + Cancel + + +
+
+
+
+ + +
+
+
+
What happens when you create a renewal?
+
+
+
    +
  • A new training record will be created with "Scheduled" status
  • +
  • The employee will be enrolled in the selected session (if chosen)
  • +
  • The original record will remain unchanged for historical tracking
  • +
  • The renewal will reference the original record in its notes
  • +
  • You can track both records separately in the system
  • +
+
+
+
+
+
+
+
+{% endblock %} + +{% block js %} + +{% endblock %} diff --git a/hr/templates/hr/training/session_detail.html b/hr/templates/hr/training/session_detail.html new file mode 100644 index 00000000..ce433ebf --- /dev/null +++ b/hr/templates/hr/training/session_detail.html @@ -0,0 +1,333 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}{{ session.title|default:session.program.name }} - Training Session{% endblock %} + +{% block css %} +{% endblock %} + +{% block content %} +
+
+
+ +
+
+ +

{{ session.title|default:session.program.name }}

+

{{ session.get_delivery_method_display }} Training Session

+
+
+
+ + Edit Session + + +
+
+
+ +
+ +
+
+
+
Session Information
+
+
+
+
+
+
Program:
+
+ + {{ session.program.name }} + +
+ +
Date:
+
{{ session.start_at|date:"l, F d, Y" }}
+ +
Time:
+
+ {{ session.start_at|time:"g:i A" }} - {{ session.end_at|time:"g:i A" }} +
+ +
Duration:
+
+ {% if session.hours_override %} + {{ session.hours_override }} hours + {% else %} + {{ session.program.duration_hours }} hours + {% endif %} +
+
+
+
+
+
Delivery:
+
+ {{ session.get_delivery_method_display }} +
+ +
Location:
+
{{ session.location|default:"TBD" }}
+ +
Instructor:
+
+ {% if session.instructor %} + {{ session.instructor.get_full_name }} + {% else %} + TBD + {% endif %} +
+ +
Cost:
+
+ {% if session.cost_override %} + ${{ session.cost_override|floatformat:2 }} + {% elif session.program.cost %} + ${{ session.program.cost|floatformat:2 }} + {% else %} + Free + {% endif %} +
+
+
+
+
+
+ + +
+
+
Enrollments ({{ total_enrolled }})
+ {% if session.capacity %} + {{ total_enrolled }}/{{ session.capacity }} enrolled + {% endif %} +
+
+ {% if enrollments %} +
+ + + + + + + + + + + + + {% for enrollment in enrollments %} + + + + + + + + + {% endfor %} + +
EmployeeDepartmentEnrolledStatusAttendanceActions
+ + {{ enrollment.employee.get_full_name }} + + + {% if enrollment.employee.department %} + {{ enrollment.employee.department.name }} + {% else %} + - + {% endif %} + {{ enrollment.enrolled_at|date:"M d, Y" }} + {% if enrollment.status == 'SCHEDULED' %} + {{ enrollment.get_status_display }} + {% elif enrollment.status == 'COMPLETED' %} + {{ enrollment.get_status_display }} + {% elif enrollment.status == 'WAITLISTED' %} + {{ enrollment.get_status_display }} + {% elif enrollment.status == 'CANCELLED' %} + {{ enrollment.get_status_display }} + {% else %} + {{ enrollment.get_status_display }} + {% endif %} + + {% for attendance in attendance_records %} + {% if attendance.enrollment.pk == enrollment.pk %} + {% if attendance.status == 'PRESENT' %} + Present + {% elif attendance.status == 'LATE' %} + Late + {% elif attendance.status == 'ABSENT' %} + Absent + {% elif attendance.status == 'EXCUSED' %} + Excused + {% endif %} + {% endif %} + {% empty %} + - + {% endfor %} + +
+ + + + +
+
+
+ {% else %} +
+ +
No Enrollments Yet
+

Start enrolling employees for this session.

+ +
+ {% endif %} +
+
+
+ + +
+
+
+
Session Statistics
+
+
+
+
+
+

{{ total_enrolled }}

+ Enrolled +
+
+
+

{{ capacity_percentage|floatformat:0 }}%

+ Capacity +
+
+ + {% if session.capacity %} +
+
+
+
+
+ {{ total_enrolled }} of {{ session.capacity }} seats filled +
+ {% endif %} +
+
+ + +
+
+
Quick Actions
+
+
+
+ + + + View Program + +
+
+
+
+
+
+
+
+ + + +{% endblock %} + +{% block js %} + +{% endblock %} diff --git a/hr/templates/hr/training/session_form.html b/hr/templates/hr/training/session_form.html new file mode 100644 index 00000000..9a3286f5 --- /dev/null +++ b/hr/templates/hr/training/session_form.html @@ -0,0 +1,355 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %} + {% if object %}Edit Training Session{% else %}Schedule Training Session{% endif %} - HR Management +{% endblock %} + +{% block css %} + + +{% endblock %} + +{% block content %} +
+
+
+ +
+
+ +

+ {% if object %}Edit Training Session{% else %}Schedule Training Session{% endif %} +

+
+
+ +
+
+
+
+
+ {% csrf_token %} + + +
+
+
Program Information
+
+ +
+
+ + {{ form.program }} + {% if form.program.errors %} +
+ {{ form.program.errors.0 }} +
+ {% endif %} +
+
+ +
+
+ + {{ form.delivery_method }} + {% if form.delivery_method.errors %} +
+ {{ form.delivery_method.errors.0 }} +
+ {% endif %} +
+
+ +
+
+ + {{ form.title }} +
Optional - defaults to program name if not provided
+ {% if form.title.errors %} +
+ {{ form.title.errors.0 }} +
+ {% endif %} +
+
+
+ + +
+
+
Schedule
+
+ +
+
+ + {{ form.start_at }} + {% if form.start_at.errors %} +
+ {{ form.start_at.errors.0 }} +
+ {% endif %} +
+
+ +
+
+ + {{ form.end_at }} + {% if form.end_at.errors %} +
+ {{ form.end_at.errors.0 }} +
+ {% endif %} +
+
+
+ + +
+
+
Location & Instructor
+
+ +
+
+ + {{ form.location }} + {% if form.location.errors %} +
+ {{ form.location.errors.0 }} +
+ {% endif %} +
+
+ +
+
+ + {{ form.instructor }} + {% if form.instructor.errors %} +
+ {{ form.instructor.errors.0 }} +
+ {% endif %} +
+
+
+ + +
+
+
Capacity & Overrides
+
+ +
+
+ + {{ form.capacity }} +
Leave empty for unlimited capacity
+ {% if form.capacity.errors %} +
+ {{ form.capacity.errors.0 }} +
+ {% endif %} +
+
+ +
+
+ + {{ form.hours_override }} +
Override program duration for this session
+ {% if form.hours_override.errors %} +
+ {{ form.hours_override.errors.0 }} +
+ {% endif %} +
+
+ +
+
+ +
+ $ + {{ form.cost_override }} +
+
Override program cost for this session
+ {% if form.cost_override.errors %} +
+ {{ form.cost_override.errors.0 }} +
+ {% endif %} +
+
+
+ + +
+
+
+
+ + Cancel + + +
+
+
+
+
+
+
+ + +
+
+
+
+ Help & Guidelines +
+
+
+
Delivery Methods
+
    +
  • In Person: Traditional classroom training
  • +
  • Virtual: Online training via video conference
  • +
  • Hybrid: Combination of in-person and virtual
  • +
  • Self Paced: Individual learning at own pace
  • +
+ +
Scheduling Tips
+
    +
  • Consider time zones for virtual sessions
  • +
  • Allow buffer time between sessions
  • +
  • Check instructor availability
  • +
  • Verify room/location availability
  • +
+ +
Capacity Planning
+

+ Set realistic capacity limits based on: +

+
    +
  • Physical room size
  • +
  • Virtual platform limits
  • +
  • Instructor-to-participant ratio
  • +
  • Training effectiveness
  • +
+ +
Overrides
+

+ Use overrides to customize this specific session without changing + the base program settings. +

+
+
+ + + {% if object and object.program %} +
+
+
Program Information
+
+
+
+
Program:
+
{{ object.program.name }}
+ +
Type:
+
{{ object.program.get_program_type_display }}
+ +
Duration:
+
{{ object.program.duration_hours }} hours
+ +
Cost:
+
+ {% if object.program.cost %} + ${{ object.program.cost|floatformat:2 }} + {% else %} + Free + {% endif %} +
+
+ + + View Program Details + +
+
+ {% endif %} +
+
+
+
+
+{% endblock %} + +{% block js %} + +{% endblock %} diff --git a/hr/templates/hr/training/session_list.html b/hr/templates/hr/training/session_list.html new file mode 100644 index 00000000..9fd83194 --- /dev/null +++ b/hr/templates/hr/training/session_list.html @@ -0,0 +1,196 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Training Sessions - HR Management{% endblock %} + +{% block css %} +{% endblock %} + +{% block content %} +
+
+
+ +
+
+

Training Sessions

+

Manage scheduled training sessions

+
+ +
+ + +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ + +
+
+ {% if sessions %} +
+ + + + + + + + + + + + + + {% for session in sessions %} + + + + + + + + + + {% endfor %} + +
SessionProgramDate & TimeLocationInstructorEnrollmentActions
+
+ {{ session.title|default:session.program.name }} +
+ {{ session.get_delivery_method_display }} + +
+
+ + {{ session.program.name }} + +
{{ session.program.get_program_type_display }} +
+
+ {{ session.start_at|date:"M d, Y" }} +
+ {{ session.start_at|time:"g:i A" }} - {{ session.end_at|time:"g:i A" }} + +
+
+ {% if session.location %} + {{ session.location }} + {% else %} + TBD + {% endif %} + + {% if session.instructor %} + {{ session.instructor.get_full_name }} + {% else %} + TBD + {% endif %} + + {% if session.capacity %} +
+
+ {{ session.enrolled_count|default:0 }}/{{ session.capacity }} +
+
+ {% else %} + {{ session.enrolled_count|default:0 }} enrolled + {% endif %} +
+ +
+
+ + + {% if is_paginated %} + + {% endif %} + {% else %} +
+ +
No Training Sessions Found
+

Schedule your first training session to get started.

+ + Schedule Session + +
+ {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/hr/templates/hr/training/training_management.html b/hr/templates/hr/training/training_management.html index adf8c89f..9584d27c 100644 --- a/hr/templates/hr/training/training_management.html +++ b/hr/templates/hr/training/training_management.html @@ -162,12 +162,12 @@
- {{ tr.training_name }} - {% if tr.training_provider %}
{{ tr.training_provider }}{% endif %} + {{ tr.program.name }} + {% if tr.program.program_provider %}
{{ tr.program.program_provider }}{% endif %}
- {{ tr.get_training_type_display }} - {{ tr.training_date|date:"M d, Y" }} + {{ tr.program.get_program_type_display }} + {{ tr.started_at|date:"M d, Y" }} {% if tr.completion_date %} {{ tr.completion_date|date:"M d, Y" }} diff --git a/hr/templates/hr/training/training_record_confirm_delete.html b/hr/templates/hr/training/training_record_confirm_delete.html index bde4717b..1b71605e 100644 --- a/hr/templates/hr/training/training_record_confirm_delete.html +++ b/hr/templates/hr/training/training_record_confirm_delete.html @@ -60,7 +60,7 @@ Delete Training Record | HR Management - + diff --git a/hr/templates/hr/training/training_record_detail.html b/hr/templates/hr/training/training_record_detail.html index 53a10767..da272ef5 100644 --- a/hr/templates/hr/training/training_record_detail.html +++ b/hr/templates/hr/training/training_record_detail.html @@ -2,13 +2,13 @@ {% load static %} {% block title %} -{{ record.title }} | Training Record +Training Record: {{ record.program.name }} | HR Management {% endblock %} {% block css %} {% endblock %} {% block content %} - - - - - -

- {% if form.instance.id %}Edit{% else %}Create{% endif %} Training Record -

- - - -
- -
- -
- -
-

- {% if form.instance.id %}Edit{% else %}Create{% endif %} Training Record -

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

+ + {% if form.instance.id %}Edit{% else %}Create{% endif %} Training Record +

+

+ {% if form.instance.id %} + Update training record for {{ form.instance.employee.get_full_name }} + {% else %} + Create a new training enrollment record + {% endif %} +

- +
+
+ +
+ +
+ + {% if form.errors %} +
+
Please correct the errors below:
+
    + {% for field in form %} + {% for error in field.errors %} +
  • {{ field.label }}: {{ error }}
  • + {% endfor %} + {% endfor %} + {% for error in form.non_field_errors %} +
  • {{ error }}
  • + {% endfor %} +
+
+ {% endif %} - -
- - {% if form.errors %} -
-
Please correct the errors below:
-
    - {% for field in form %} - {% for error in field.errors %} -
  • {{ field.label }}: {{ error }}
  • - {% endfor %} - {% endfor %} - {% for error in form.non_field_errors %} -
  • {{ error }}
  • - {% endfor %} -
-
- {% endif %} +
+ {% csrf_token %} - - {% csrf_token %} - - -
-
Basic Information
-
-
- - {{ form.employee }} - {% if form.employee.errors %} -
{{ form.employee.errors }}
- {% endif %} -
-
- - {{ form.title }} - {% if form.title.errors %} -
{{ form.title.errors }}
- {% endif %} -
-
- - {{ form.training_type }} - {% if form.training_type.errors %} -
{{ form.training_type.errors }}
- {% endif %} -
-
- - {{ form.provider }} - {% if form.provider.errors %} -
{{ form.provider.errors }}
- {% endif %} -
-
- - {{ form.start_date }} - {% if form.start_date.errors %} -
{{ form.start_date.errors }}
- {% endif %} -
-
- - {{ form.end_date }} - {% if form.end_date.errors %} -
{{ form.end_date.errors }}
- {% endif %} -
-
- - {{ form.status }} - {% if form.status.errors %} -
{{ form.status.errors }}
- {% endif %} -
-
-
- {{ form.is_mandatory }} - -
- {% if form.is_mandatory.errors %} -
{{ form.is_mandatory.errors }}
- {% endif %} -
-
-
- - -
-
Description
-
-
- - {{ form.description }} - {% if form.description.errors %} -
{{ form.description.errors }}
- {% endif %} -
-
- -
Learning Objectives
-
- - {% if objectives %} - {% for objective in objectives %} -
-
-
- - -
-
- -
-
-
- {% endfor %} + +
+
+ Basic Information +
+
+
+ + {{ form.employee }} + {% if form.employee.help_text %} +
{{ form.employee.help_text }}
+ {% endif %} + {% if form.employee.errors %} +
{{ form.employee.errors.0 }}
{% endif %}
-
- +
+ + {{ form.program }} + {% if form.program.help_text %} +
{{ form.program.help_text }}
+ {% endif %} + {% if form.program.errors %} +
{{ form.program.errors.0 }}
+ {% endif %} +
+
+ + {{ form.session }} + {% if form.session.help_text %} +
{{ form.session.help_text }}
+ {% endif %} + {% if form.session.errors %} +
{{ form.session.errors.0 }}
+ {% endif %} +
+
+ + {{ form.status }} + {% if form.status.help_text %} +
{{ form.status.help_text }}
+ {% endif %} + {% if form.status.errors %} +
{{ form.status.errors.0 }}
+ {% endif %}
- - -
-
Completion Details
-
-
- - {{ form.completion_date }} - {% if form.completion_date.errors %} -
{{ form.completion_date.errors }}
- {% endif %} -
-
- - {{ form.score_grade }} - {% if form.score_grade.errors %} -
{{ form.score_grade.errors }}
- {% endif %} -
-
- - {{ form.hours_completed }} - {% if form.hours_completed.errors %} -
{{ form.hours_completed.errors }}
- {% endif %} -
-
- -
- {{ form.completion_percentage }} - % -
- {% if form.completion_percentage.errors %} -
{{ form.completion_percentage.errors }}
- {% endif %} -
-
- - {{ form.completion_notes }} - {% if form.completion_notes.errors %} -
{{ form.completion_notes.errors }}
- {% endif %} -
+
+ + +
+
+ Schedule Information +
+
+
+ + {{ form.started_at }} + {% if form.started_at.help_text %} +
{{ form.started_at.help_text }}
+ {% endif %} + {% if form.started_at.errors %} +
{{ form.started_at.errors.0 }}
+ {% endif %} +
+
+ + {{ form.completion_date }} +
Required for completed training
+ {% if form.completion_date.errors %} +
{{ form.completion_date.errors.0 }}
+ {% endif %}
- - -
-
Certification
-
-
-
- {{ form.is_certified }} - -
- {% if form.is_certified.errors %} -
{{ form.is_certified.errors }}
- {% endif %} +
+ + +
+
+ Progress & Assessment +
+
+
+ +
+ {{ form.score }} + %
+ {% if form.score.help_text %} +
{{ form.score.help_text }}
+ {% endif %} + {% if form.score.errors %} +
{{ form.score.errors.0 }}
+ {% endif %}
+
+ + {{ form.credits_earned }} + {% if form.credits_earned.help_text %} +
{{ form.credits_earned.help_text }}
+ {% endif %} + {% if form.credits_earned.errors %} +
{{ form.credits_earned.errors.0 }}
+ {% endif %} +
+
+ +
+ $ + {{ form.cost_paid }} +
+ {% if form.cost_paid.help_text %} +
{{ form.cost_paid.help_text }}
+ {% endif %} + {% if form.cost_paid.errors %} +
{{ form.cost_paid.errors.0 }}
+ {% endif %} +
+
+
+ {{ form.passed }} + +
+
Check if the training was successfully completed
+ {% if form.passed.errors %} +
{{ form.passed.errors.0 }}
+ {% endif %} +
+
+
+ + +
+
+ Additional Notes +
+
+
+ + {{ form.notes }} + {% if form.notes.help_text %} +
{{ form.notes.help_text }}
+ {% endif %} + {% if form.notes.errors %} +
{{ form.notes.errors.0 }}
+ {% endif %} +
+
+
+ + +
+ + Cancel + +
+ {% if form.instance.id %} + + {% else %} + + {% endif %} +
+
+ +
+ + +
+
+ +
+
+
+ Help & Guidelines +
+
+
+
Training Record Guidelines
+

Follow these steps to create a comprehensive training record:

+
    +
  1. Select the employee and training program
  2. +
  3. Choose a specific session if available
  4. +
  5. Set the appropriate status
  6. +
  7. Add start date/time and completion date
  8. +
  9. Record assessment results and credits
  10. +
  11. Add any relevant notes
  12. +
-
-
-
- - {{ form.certificate_number }} - {% if form.certificate_number.errors %} -
{{ form.certificate_number.errors }}
- {% endif %} -
-
- - {{ form.expiration_date }} - {% if form.expiration_date.errors %} -
{{ form.expiration_date.errors }}
- {% endif %} -
+
Status Definitions
+
+
+ Scheduled + Planned but not started
- -
Certificate Files
-
- - {% if certificates %} - {% for certificate in certificates %} -
-
-
- - - -
-
- -
- - - - -
-
-
- -
- -
-
-
-
- {% endfor %} - {% endif %} +
+ In Progress + Currently underway
- -
-
- - -
-
- - -
-
- -
+
+ Completed + Successfully finished +
+
+ Cancelled + Training was cancelled +
+
+ Failed + Did not meet requirements
- - -
-
Attachments
-
-
- - {{ form.attachments }} -
You can upload multiple files (PDF, Word, Excel, etc.)
- {% if form.attachments.errors %} -
{{ form.attachments.errors }}
- {% endif %} +
+ + +
+
+
+ Tips +
+
+
+
    +
  • Select a training session to automatically populate program details
  • +
  • Completion date is required for completed training
  • +
  • Score should be entered as a percentage (0-100)
  • +
  • Credits earned are used for continuing education tracking
  • +
  • Use notes for special considerations or follow-up requirements
  • +
  • Mark as "Passed" only for successfully completed training
  • +
+
+
+ + + {% if form.instance.program %} +
+
+
+ Program Information +
+
+
+
{{ form.instance.program.name }}
+

{{ form.instance.program.description|truncatewords:20 }}

+
+
+ Type:
+ {{ form.instance.program.get_program_type_display }} +
+
+ Duration:
+ {{ form.instance.program.duration_hours }} hours
- - {% if existing_attachments %} -
-
Existing Attachments
-
- {% for attachment in existing_attachments %} -
- -
- - -
-
- {% endfor %} -
+ {% if form.instance.program.is_certified %} +
+ + Certified Program +
{% endif %}
- - -
-
Additional Notes
-
-
- - {{ form.notes }} - {% if form.notes.errors %} -
{{ form.notes.errors }}
- {% endif %} -
-
-
- - -
- - Cancel - -
- - -
-
- -
- -
- -
- - - -
-
- -
-
-
Help & Guidelines
-
-
-
Training Record Guidelines
-

Follow these steps to create a comprehensive training record:

-
    -
  1. Fill in the basic information about the training
  2. -
  3. Add a detailed description and learning objectives
  4. -
  5. If the training is completed, add completion details
  6. -
  7. For certifications, add certificate information and files
  8. -
  9. Attach any relevant documents
  10. -
  11. Add any additional notes
  12. -
- -
Training Types
-
    -
  • Certification: Formal qualification with certificate
  • -
  • Course: Structured learning program
  • -
  • Workshop: Hands-on practical training
  • -
  • Seminar: Educational presentation
  • -
  • Conference: Industry event with multiple sessions
  • -
  • Mandatory: Required by regulation or policy
  • -
- -
Status Definitions
-
    -
  • Scheduled: Planned but not yet started
  • -
  • In Progress: Currently underway
  • -
  • Completed: Successfully finished
  • -
  • Expired: Certification has expired
  • -
  • Upcoming: Scheduled to start soon
  • -
-
-
- - -
-
-
Tips
-
-
-
    -
  • For mandatory trainings, be sure to include expiration dates if applicable
  • -
  • Learning objectives should be specific and measurable
  • -
  • Upload certificates as soon as they are available
  • -
  • For in-progress trainings, update the completion percentage regularly
  • -
  • Use the notes section for any special considerations or follow-up requirements
  • -
+ {% endif %}
-
- {% endblock %} {% block js %} - - - - - - - {% endblock %} - diff --git a/hr/templates/hr/training/training_record_list.html b/hr/templates/hr/training/training_record_list.html index 9143838f..9bc92bc4 100644 --- a/hr/templates/hr/training/training_record_list.html +++ b/hr/templates/hr/training/training_record_list.html @@ -1,33 +1,28 @@ {% extends "base.html" %} {% load static %} -{% block title %} -Training Records | HR Management -{% endblock %} +{% block title %}Training Records | HR Management{% endblock %} {% block css %} - - - - - - - - {% endblock %} {% block content %} - - - +
+
+
+ +
+
+ +

Training Records

+

Manage employee training and certifications

+
+ +
- -

- Training Records - Manage employee training and certifications -

- - - -
- -
- -
- -
-
-
-
-
-
-
{{ total_trainings|default:"0" }}
-
Total Trainings
-
-
-
-
-
- - -
-
-
-
-
-
-
{{ completed_trainings|default:"0" }}
-
Completed Trainings
-
-
-
-
-
- - -
-
-
-
-
-
-
{{ in_progress_trainings|default:"0" }}
-
In Progress
-
-
-
-
-
- - -
-
-
-
-
-
-
{{ expiring_soon|default:"0" }}
-
Expiring Soon
-
-
-
-
-
-
- - - -
- -
-

Training Records

-
- - - -
-
- - - -
- -
-
-
-
-
Filters
-
- - + +
+
+
+
+
+
+ +
+
+
{{ training_records.count }}
+
Total Records
-
- - +
+
+ +
+
+
+
+
+ +
+
+
{{ completed_count|default:"0" }}
+
Completed
+
+
-
- - +
+
+ +
+
+
+
+
+ +
+
+
{{ in_progress_count|default:"0" }}
+
In Progress
+
+
-
- - +
+
+ +
+
+
+
+
+ +
+
+
{{ expiring_count|default:"0" }}
+
Expiring Soon
+
+
-
- - +
+
+
+ + +
+
+
+
Filters & Search
+
+ +
-
-
-
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ -
- - Add Training Record - -
- - -
-
+ + Reset + +
+
-
+
- - - -
- - - - - - - - - - - - - - - {% for record in training_records %} - - - - - - - - - - - {% empty %} - - - - {% endfor %} - -
EmployeeTraining TitleTypeProviderStart DateEnd DateStatusActions
{{ record.employee.get_full_name }}{{ record.title }}{{ record.get_training_type_display }}{{ record.provider }}{{ record.start_date|date:"M d, Y" }}{{ record.end_date|date:"M d, Y" }} - - {{ record.get_status_display }} - - - -
No training records found.
-
- - - -
-
- {% for record in training_records %} -
-
-
-
{{ record.title }}
- - {{ record.get_status_display }} - -
-
-
- Employee: {{ record.employee.get_full_name }} -
-
- Type: {{ record.get_training_type_display }} -
-
- Provider: {{ record.provider }} -
-
- Duration: {{ record.start_date|date:"M d, Y" }} - {{ record.end_date|date:"M d, Y" }} -
- {% if record.completion_date %} -
- Completed: {{ record.completion_date|date:"M d, Y" }} -
- {% endif %} - {% if record.expiration_date %} -
- Expires: {{ record.expiration_date|date:"M d, Y" }} -
- {% endif %} -
- -
-
- {% empty %} -
-
- No training records found. -
-
- {% endfor %} -
-
-
- + + +
+
+
Training Records ({{ training_records.count }} total)
+
+
+
+ + + + + + + + + + + + + + {% for record in training_records %} + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
EmployeeProgramSessionEnrolledProgressStatusActions
+
+ {% if record.employee.profile_picture %} + {{ record.employee.get_full_name }} + {% else %} +
+ {{ record.employee.first_name|first }}{{ record.employee.last_name|first }} +
+ {% endif %} +
+
{{ record.employee.get_full_name }}
+
{{ record.employee.job_title }}
+
+
+
+
+
{{ record.program.name }}
+
{{ record.program.get_program_type_display }}
+
+
+ {% if record.session %} +
{{ record.session.title|default:record.session.program.name }}
+
{{ record.session.start_at|date:"M d, Y" }}
+ {% else %} + No session assigned + {% endif %} +
+
{{ record.enrolled_at|date:"M d, Y" }}
+
{{ record.enrolled_at|timesince }} ago
+
+
+
+
+
+ {{ record.completion_percentage }}% +
+
+ + {{ record.get_status_display }} + + {% if record.program.is_certified and record.status == 'COMPLETED' %} +
Certified + {% endif %} +
+
+ + + + + + + {% if record.status != 'COMPLETED' %} + + + + {% endif %} + +
+
+
+ +
No Training Records Found
+

No training records match your current filters.

+ + Add First Training Record + +
+
+
+ + + {% if is_paginated %} + {% include 'partial/pagination.html' %} + + {% endif %} +
+
+ + +
+
+ {% for record in training_records %} +
+
+
+
{{ record.program.name|truncatechars:30 }}
+ + {{ record.get_status_display }} + +
+
+
+ {% if record.employee.profile_picture %} + {{ record.employee.get_full_name }} + {% else %} +
+ {{ record.employee.first_name|first }}{{ record.employee.last_name|first }} +
+ {% endif %} +
+
{{ record.employee.get_full_name }}
+
{{ record.employee.job_title }}
+
+
+ +
+ Type: {{ record.program.get_program_type_display }} +
+ + {% if record.session %} +
+ Session: {{ record.session.start_at|date:"M d, Y" }} +
+ {% endif %} + +
+ Enrolled: {{ record.enrolled_at|date:"M d, Y" }} +
+ + {% if record.completion_date %} +
+ Completed: {{ record.completion_date|date:"M d, Y" }} +
+ {% endif %} + +
+
+ Progress + {{ record.completion_percentage }}% +
+
+
+
+
+ + {% if record.program.is_certified and record.status == 'COMPLETED' %} +
+ Certified Training +
+ {% endif %} +
+ +
+
+ {% empty %} +
+
+ +
No Training Records Found
+

No training records match your current filters.

+ + Add First Training Record + +
+
+ {% endfor %} +
+ + + {% if is_paginated %} + {% include 'partial/pagination.html' %} + {% endif %} +
-
-
- {% endblock %} {% block js %} - - - - - - - - - - - - - - - - - - {% endblock %} - diff --git a/hr/urls.py b/hr/urls.py index 9ad95c43..6538c9e9 100644 --- a/hr/urls.py +++ b/hr/urls.py @@ -76,14 +76,50 @@ urlpatterns = [ path('reviews//complete/', views.complete_performance_review, name='complete_performance_review'), # ============================================================================ - # TRAINING RECORD URLS (FULL CRUD - Operational Data) + # TRAINING MANAGEMENT URLS (FULL CRUD - Operational Data) # ============================================================================ - path('training-management', views.TrainingManagementView.as_view(), name='training_management'), - path('training/', views.TrainingRecordListView.as_view(), name='training_record_list'), - path('training/create/', views.TrainingRecordCreateView.as_view(), name='training_record_create'), - path('training//', views.TrainingRecordDetailView.as_view(), name='training_record_detail'), - path('training//update/', views.TrainingRecordUpdateView.as_view(), name='training_record_update'), - path('training//delete/', views.TrainingRecordDeleteView.as_view(), name='training_record_delete'), + + # Training Management Dashboard + path('training-management/', views.TrainingManagementView.as_view(), name='training_management'), + path('training/analytics/', views.training_analytics, name='training_analytics'), + + # Training Programs + path('training/programs/', views.TrainingProgramListView.as_view(), name='training_program_list'), + path('training/programs/create/', views.TrainingProgramCreateView.as_view(), name='training_program_create'), + path('training/programs//', views.TrainingProgramDetailView.as_view(), name='training_program_detail'), + path('training/programs//update/', views.TrainingProgramUpdateView.as_view(), name='training_program_update'), + + # Training Sessions + path('training/sessions/', views.TrainingSessionListView.as_view(), name='training_session_list'), + path('training/sessions/create/', views.TrainingSessionCreateView.as_view(), name='training_session_create'), + path('training/sessions//', views.TrainingSessionDetailView.as_view(), name='training_session_detail'), + path('training/sessions//update/', views.TrainingSessionUpdateView.as_view(), name='training_session_update'), + + # Training Enrollments/Records + path('training/records/', views.TrainingRecordListView.as_view(), name='training_record_list'), + path('training/records/create/', views.TrainingRecordCreateView.as_view(), name='training_record_create'), + path('training/records//', views.TrainingRecordDetailView.as_view(), name='training_record_detail'), + path('training/records//update/', views.TrainingRecordUpdateView.as_view(), name='training_record_update'), + path('training/records//delete/', views.TrainingRecordDeleteView.as_view(), name='training_record_delete'), + + # Training Record Management Actions + path('training/records//mark-complete/', views.training_record_mark_complete, name='training_record_mark_complete'), + path('training/records//renew/', views.training_record_renew, name='training_record_renew'), + path('training/records//mark-expired/', views.training_record_mark_expired, name='training_record_mark_expired'), + path('training/records//archive/', views.training_record_archive, name='training_record_archive'), + path('training/records//duplicate/', views.training_record_duplicate, name='training_record_duplicate'), + + # Training Certificates + path('training/certificates/', views.TrainingCertificateListView.as_view(), name='training_certificate_list'), + path('training/certificates//', views.TrainingCertificateDetailView.as_view(), name='training_certificate_detail'), + path('training/certificates/issue//', views.issue_certificate, name='issue_certificate'), + + # Training Actions + path('training/sessions//enroll/', views.enroll_employee, name='enroll_employee'), + path('training/enrollments//attendance/', views.mark_attendance, name='mark_attendance'), + + # Employee Training Transcript + path('employees//training-transcript/', views.employee_training_transcript, name='employee_training_transcript'), # ============================================================================ # HTMX ENDPOINTS FOR REAL-TIME UPDATES @@ -101,9 +137,14 @@ urlpatterns = [ path('schedules//publish/', views.publish_schedule, name='publish_schedule'), + # ============================================================================ + # AJAX ENDPOINTS FOR DYNAMIC FORM FUNCTIONALITY + # ============================================================================ + path('ajax/get-program-sessions/', views.get_program_sessions, name='get_program_sessions'), + path('ajax/get-program-details/', views.get_program_details, name='get_program_details'), + # ============================================================================ # API ENDPOINTS # ============================================================================ path('api/', include('hr.api.urls')), ] - diff --git a/hr/views.py b/hr/views.py index d8e6077d..5ded68a0 100644 --- a/hr/views.py +++ b/hr/views.py @@ -22,7 +22,9 @@ import json from accounts.models import User from .models import ( Employee, Department, Schedule, ScheduleAssignment, - TimeEntry, PerformanceReview, TrainingRecord + TimeEntry, PerformanceReview, TrainingPrograms, TrainingSession, + TrainingRecord, ProgramModule, ProgramPrerequisite, + TrainingAttendance, TrainingAssessment, TrainingCertificates ) from .forms import ( EmployeeForm, DepartmentForm, ScheduleForm, ScheduleAssignmentForm, @@ -820,10 +822,11 @@ class TrainingManagementView(LoginRequiredMixin, ListView): paginate_by = 20 def get_queryset(self): + tenant = self.request.user.tenant qs = (TrainingRecord.objects - .filter(employee__tenant=self.request.user.tenant) + .filter(employee__tenant=tenant) .select_related('employee', 'employee__department') - .order_by('-training_date', '-completion_date')) + .order_by( '-completion_date')) # optional GET filters (works with the template’s inputs) if emp := self.request.GET.get('employee'): qs = qs.filter(employee_id=emp) @@ -947,7 +950,8 @@ class TrainingRecordDetailView(LoginRequiredMixin, DetailView): context_object_name = 'record' def get_queryset(self): - return TrainingRecord.objects.filter(employee__tenant=self.request.user.tenant) + tenant = self.request.user.tenant + return TrainingRecord.objects.filter(employee__tenant=tenant) class TrainingRecordCreateView(LoginRequiredMixin, CreateView): @@ -1001,6 +1005,7 @@ class TrainingRecordDeleteView(LoginRequiredMixin, DeleteView): model = TrainingRecord template_name = 'hr/training/training_record_confirm_delete.html' success_url = reverse_lazy('hr:training_record_list') + context_object_name = 'record' def get_queryset(self): return TrainingRecord.objects.filter(employee__tenant=self.request.user.tenant) @@ -3475,3 +3480,886 @@ def employee_schedule(request, employee_id): return render(request, 'hr/employee_schedule.html', context) + +# =========================== +# Training Program Views +# =========================== + +class TrainingProgramListView(LoginRequiredMixin, ListView): + """List all training programs.""" + model = TrainingPrograms + template_name = 'hr/training/program_list.html' + context_object_name = 'programs' + paginate_by = 20 + + def get_queryset(self): + queryset = TrainingPrograms.objects.filter(tenant=self.request.user.tenant) + + # Search functionality + search = self.request.GET.get('search') + if search: + queryset = queryset.filter( + Q(name__icontains=search) | + Q(description__icontains=search) | + Q(program_provider__icontains=search) + ) + + # Filter by program type + program_type = self.request.GET.get('program_type') + if program_type: + queryset = queryset.filter(program_type=program_type) + + # Filter by certification status + is_certified = self.request.GET.get('is_certified') + if is_certified: + queryset = queryset.filter(is_certified=(is_certified == 'true')) + + return queryset.select_related('instructor').order_by('name') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['program_types'] = TrainingPrograms.TrainingType.choices + context['search'] = self.request.GET.get('search', '') + context['selected_type'] = self.request.GET.get('program_type', '') + context['selected_certified'] = self.request.GET.get('is_certified', '') + return context + + +class TrainingProgramDetailView(LoginRequiredMixin, DetailView): + """Display detailed information about a training program.""" + model = TrainingPrograms + template_name = 'hr/training/program_detail.html' + context_object_name = 'program' + + def get_queryset(self): + return TrainingPrograms.objects.filter(tenant=self.request.user.tenant) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + program = self.get_object() + + # Get program modules + context['modules'] = ProgramModule.objects.filter(program=program).order_by('order') + + # Get prerequisites + context['prerequisites'] = ProgramPrerequisite.objects.filter( + program=program + ).select_related('required_program') + + # Get upcoming sessions + context['upcoming_sessions'] = TrainingSession.objects.filter( + program=program, + start_at__gte=timezone.now() + ).order_by('start_at')[:5] + + # Get recent completions + context['recent_completions'] = TrainingRecord.objects.filter( + program=program, + status='COMPLETED' + ).select_related('employee').order_by('-completion_date')[:10] + + # Statistics + context['total_enrollments'] = TrainingRecord.objects.filter(program=program).count() + context['completion_rate'] = 0 + if context['total_enrollments'] > 0: + completed = TrainingRecord.objects.filter(program=program, status='COMPLETED').count() + context['completion_rate'] = round((completed / context['total_enrollments']) * 100, 1) + + return context + + +class TrainingProgramCreateView(LoginRequiredMixin, CreateView): + """Create a new training program.""" + model = TrainingPrograms + template_name = 'hr/training/program_form.html' + fields = [ + 'name', 'description', 'program_type', 'program_provider', 'instructor', + 'start_date', 'end_date', 'duration_hours', 'cost', 'is_certified', + 'validity_days', 'notify_before_days' + ] + + def form_valid(self, form): + form.instance.tenant = self.request.user.tenant + form.instance.created_by = self.request.user.employee_profile + messages.success(self.request, 'Training program created successfully.') + return super().form_valid(form) + + def get_success_url(self): + return reverse('hr:training_program_detail', kwargs={'pk': self.object.pk}) + + +class TrainingProgramUpdateView(LoginRequiredMixin, UpdateView): + """Update an existing training program.""" + model = TrainingPrograms + template_name = 'hr/training/program_form.html' + fields = [ + 'name', 'description', 'program_type', 'program_provider', 'instructor', + 'start_date', 'end_date', 'duration_hours', 'cost', 'is_certified', + 'validity_days', 'notify_before_days' + ] + + def get_queryset(self): + return TrainingPrograms.objects.filter(tenant=self.request.user.tenant) + + def form_valid(self, form): + messages.success(self.request, 'Training program updated successfully.') + return super().form_valid(form) + + def get_success_url(self): + return reverse('hr:training_program_detail', kwargs={'pk': self.object.pk}) + + +# =========================== +# Training Session Views +# =========================== + +class TrainingSessionListView(LoginRequiredMixin, ListView): + """List all training sessions.""" + model = TrainingSession + template_name = 'hr/training/session_list.html' + context_object_name = 'sessions' + paginate_by = 20 + + def get_queryset(self): + queryset = TrainingSession.objects.filter( + program__tenant=self.request.user.tenant + ).select_related('program', 'instructor') + + # Filter by program + program_id = self.request.GET.get('program') + if program_id: + queryset = queryset.filter(program_id=program_id) + + # Filter by delivery method + delivery_method = self.request.GET.get('delivery_method') + if delivery_method: + queryset = queryset.filter(delivery_method=delivery_method) + + # Filter by date range + start_date = self.request.GET.get('start_date') + if start_date: + queryset = queryset.filter(start_at__date__gte=start_date) + + end_date = self.request.GET.get('end_date') + if end_date: + queryset = queryset.filter(start_at__date__lte=end_date) + + return queryset.order_by('start_at') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['programs'] = TrainingPrograms.objects.filter( + tenant=self.request.user.tenant + ).order_by('name') + context['delivery_methods'] = TrainingSession.TrainingDelivery.choices + return context + + +class TrainingSessionDetailView(LoginRequiredMixin, DetailView): + """Display detailed information about a training session.""" + model = TrainingSession + template_name = 'hr/training/session_detail.html' + context_object_name = 'session' + + def get_queryset(self): + return TrainingSession.objects.filter( + program__tenant=self.request.user.tenant + ).select_related('program', 'instructor') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + session = self.get_object() + + # Get enrollments + context['enrollments'] = TrainingRecord.objects.filter( + session=session + ).select_related('employee').order_by('enrolled_at') + + # Get attendance records + context['attendance_records'] = TrainingAttendance.objects.filter( + enrollment__session=session + ).select_related('enrollment__employee') + + # Statistics + context['total_enrolled'] = context['enrollments'].count() + context['capacity_percentage'] = 0 + if session.capacity > 0: + context['capacity_percentage'] = round( + (context['total_enrolled'] / session.capacity) * 100, 1 + ) + + return context + + +class TrainingSessionCreateView(LoginRequiredMixin, CreateView): + """Create a new training session.""" + model = TrainingSession + template_name = 'hr/training/session_form.html' + fields = [ + 'program', 'title', 'instructor', 'delivery_method', 'start_at', 'end_at', + 'location', 'capacity', 'cost_override', 'hours_override' + ] + + def form_valid(self, form): + form.instance.created_by = self.request.user.employee_profile + messages.success(self.request, 'Training session created successfully.') + return super().form_valid(form) + + def get_success_url(self): + return reverse('hr:training_session_detail', kwargs={'pk': self.object.pk}) + + +class TrainingSessionUpdateView(LoginRequiredMixin, UpdateView): + """Update an existing training session.""" + model = TrainingSession + template_name = 'hr/training/session_form.html' + fields = [ + 'program', 'title', 'instructor', 'delivery_method', 'start_at', 'end_at', + 'location', 'capacity', 'cost_override', 'hours_override' + ] + + def get_queryset(self): + return TrainingSession.objects.filter(program__tenant=self.request.user.tenant) + + def form_valid(self, form): + messages.success(self.request, 'Training session updated successfully.') + return super().form_valid(form) + + def get_success_url(self): + return reverse('hr:training_session_detail', kwargs={'pk': self.object.pk}) + + +# =========================== +# Training Enrollment Views +# =========================== + +@login_required +def enroll_employee(request, session_id): + """Enroll an employee in a training session.""" + session = get_object_or_404( + TrainingSession, + pk=session_id, + program__tenant=request.user.tenant + ) + + if request.method == 'POST': + employee_id = request.POST.get('employee_id') + employee = get_object_or_404( + Employee, + pk=employee_id, + tenant=request.user.tenant + ) + + # Check if already enrolled + existing_enrollment = TrainingRecord.objects.filter( + employee=employee, + session=session + ).first() + + if existing_enrollment: + messages.warning(request, f'{employee.get_full_name()} is already enrolled in this session.') + else: + # Check capacity + current_enrollments = TrainingRecord.objects.filter(session=session).count() + status = 'SCHEDULED' + if session.capacity and current_enrollments >= session.capacity: + status = 'WAITLISTED' + + # Create enrollment + TrainingRecord.objects.create( + employee=employee, + program=session.program, + session=session, + status=status, + created_by=request.user + ) + + if status == 'WAITLISTED': + messages.info(request, f'{employee.get_full_name()} has been added to the waitlist.') + else: + messages.success(request, f'{employee.get_full_name()} has been enrolled successfully.') + + return redirect('hr:training_session_detail', pk=session.pk) + + +@login_required +def mark_attendance(request, enrollment_id): + """Mark attendance for a training enrollment.""" + enrollment = get_object_or_404( + TrainingRecord, + pk=enrollment_id, + employee__tenant=request.user.tenant + ) + + if request.method == 'POST': + status = request.POST.get('status', 'PRESENT') + notes = request.POST.get('notes', '') + + # Create or update attendance record + attendance, created = TrainingAttendance.objects.get_or_create( + enrollment=enrollment, + defaults={ + 'status': status, + 'notes': notes, + 'checked_in_at': timezone.now() if status in ['PRESENT', 'LATE'] else None + } + ) + + if not created: + attendance.status = status + attendance.notes = notes + if status in ['PRESENT', 'LATE'] and not attendance.checked_in_at: + attendance.checked_in_at = timezone.now() + attendance.save() + + messages.success(request, f'Attendance marked for {enrollment.employee.get_full_name()}.') + + return redirect('hr:training_session_detail', pk=enrollment.session.pk) + + +class TrainingCertificateListView(LoginRequiredMixin, ListView): + """List all training certificates.""" + model = TrainingCertificates + template_name = 'hr/training/certificate_list.html' + context_object_name = 'certificates' + paginate_by = 20 + + def get_queryset(self): + queryset = TrainingCertificates.objects.filter( + employee__tenant=self.request.user.tenant + ).select_related('employee', 'program', 'enrollment') + + # Filter by employee + employee_id = self.request.GET.get('employee') + if employee_id: + queryset = queryset.filter(employee_id=employee_id) + + # Filter by program + program_id = self.request.GET.get('program') + if program_id: + queryset = queryset.filter(program_id=program_id) + + # Filter by expiry status + expiry_status = self.request.GET.get('expiry_status') + today = timezone.now().date() + if expiry_status == 'valid': + queryset = queryset.filter( + Q(expiry_date__isnull=True) | Q(expiry_date__gt=today) + ) + elif expiry_status == 'expiring': + queryset = queryset.filter( + expiry_date__gt=today, + expiry_date__lte=today + timedelta(days=30) + ) + elif expiry_status == 'expired': + queryset = queryset.filter(expiry_date__lt=today) + + return queryset.order_by('-issued_date') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['employees'] = Employee.objects.filter( + tenant=self.request.user.tenant, + employment_status='ACTIVE' + ).order_by('last_name', 'first_name') + context['programs'] = TrainingPrograms.objects.filter( + tenant=self.request.user.tenant, + is_certified=True + ).order_by('name') + return context + + +class TrainingCertificateDetailView(LoginRequiredMixin, DetailView): + """Display detailed information about a training certificate.""" + model = TrainingCertificates + template_name = 'hr/training/certificate_detail.html' + context_object_name = 'certificate' + + def get_queryset(self): + return TrainingCertificates.objects.filter( + employee__tenant=self.request.user.tenant + ).select_related('employee', 'program', 'enrollment', 'signed_by') + + +@login_required +def issue_certificate(request, enrollment_id): + """Issue a certificate for a completed training enrollment.""" + enrollment = get_object_or_404( + TrainingRecord, + pk=enrollment_id, + employee__tenant=request.user.tenant, + status='COMPLETED', + passed=True, + program__is_certified=True + ) + + # Check if certificate already exists + existing_certificate = TrainingCertificates.objects.filter(enrollment=enrollment).first() + if existing_certificate: + messages.warning(request, 'Certificate already exists for this enrollment.') + return redirect('hr:training_certificate_detail', pk=existing_certificate.pk) + + if request.method == 'POST': + certificate_name = request.POST.get('certificate_name', f'{enrollment.program.name} Certificate') + certification_body = request.POST.get('certification_body', 'Hospital Training Department') + + # Calculate expiry date + expiry_date = None + if enrollment.program.validity_days: + expiry_date = enrollment.completion_date + timedelta(days=enrollment.program.validity_days) + + # Generate certificate number + certificate_number = f"CERT-{enrollment.program.tenant.id}-{timezone.now().strftime('%Y%m%d')}-{enrollment.id}" + + # Create certificate + certificate = TrainingCertificates.objects.create( + program=enrollment.program, + employee=enrollment.employee, + enrollment=enrollment, + certificate_name=certificate_name, + certificate_number=certificate_number, + certification_body=certification_body, + expiry_date=expiry_date, + created_by=request.user.employee_profile, + signed_by=request.user.employee_profile + ) + + messages.success(request, f'Certificate issued successfully for {enrollment.employee.get_full_name()}.') + return redirect('hr:training_certificate_detail', pk=certificate.pk) + + context = { + 'enrollment': enrollment, + 'suggested_name': f'{enrollment.program.name} Certificate', + 'suggested_body': 'Hospital Training Department' + } + return render(request, 'hr/training/issue_certificate.html', context) + + +@login_required +def training_analytics(request): + """Display training analytics and reports.""" + tenant = request.user.tenant + today = timezone.now().date() + + # Basic statistics + total_programs = TrainingPrograms.objects.filter(tenant=tenant).count() + total_sessions = TrainingSession.objects.filter(program__tenant=tenant).count() + total_enrollments = TrainingRecord.objects.filter(employee__tenant=tenant).count() + total_certificates = TrainingCertificates.objects.filter(employee__tenant=tenant).count() + + # Completion rates by program type + program_stats = [] + for program_type, display_name in TrainingPrograms.TrainingType.choices: + programs = TrainingPrograms.objects.filter(tenant=tenant, program_type=program_type) + if programs.exists(): + total_enrollments_type = TrainingRecord.objects.filter(program__in=programs).count() + completed_enrollments = TrainingRecord.objects.filter( + program__in=programs, status='COMPLETED' + ).count() + completion_rate = 0 + if total_enrollments_type > 0: + completion_rate = round((completed_enrollments / total_enrollments_type) * 100, 1) + + program_stats.append({ + 'type': display_name, + 'total_programs': programs.count(), + 'total_enrollments': total_enrollments_type, + 'completion_rate': completion_rate + }) + + # Expiring certificates + expiring_certificates = TrainingCertificates.objects.filter( + employee__tenant=tenant, + expiry_date__gte=today, + expiry_date__lte=today + timedelta(days=30) + ).select_related('employee', 'program').order_by('expiry_date') + + # Training compliance by department + department_compliance = [] + departments = Department.objects.filter(tenant=tenant) + for dept in departments: + dept_employees = Employee.objects.filter(department=dept, employment_status='ACTIVE') + if dept_employees.exists(): + total_required = dept_employees.count() * 5 # Assume 5 mandatory trainings per employee + completed_trainings = TrainingRecord.objects.filter( + employee__in=dept_employees, + status='COMPLETED', + program__program_type='MANDATORY' + ).count() + compliance_rate = round((completed_trainings / total_required) * 100, 1) if total_required > 0 else 0 + + department_compliance.append({ + 'department': dept.name, + 'employees': dept_employees.count(), + 'compliance_rate': compliance_rate + }) + + context = { + 'total_programs': total_programs, + 'total_sessions': total_sessions, + 'total_enrollments': total_enrollments, + 'total_certificates': total_certificates, + 'program_stats': program_stats, + 'expiring_certificates': expiring_certificates, + 'department_compliance': department_compliance, + } + + return render(request, 'hr/training/analytics.html', context) + + +@login_required +def employee_training_transcript(request, employee_id): + """Display an employee's complete training transcript.""" + employee = get_object_or_404( + Employee, + pk=employee_id, + tenant=request.user.tenant + ) + + # Get all training records + training_records = TrainingRecord.objects.filter( + employee=employee + ).select_related('program', 'session').order_by('-completion_date', '-enrolled_at') + + # Get all certificates + certificates = TrainingCertificates.objects.filter( + employee=employee + ).select_related('program').order_by('-issued_date') + + # Calculate statistics + total_hours = sum(record.credits_earned for record in training_records if record.credits_earned) + completed_trainings = training_records.filter(status='COMPLETED').count() + + context = { + 'employee': employee, + 'training_records': training_records, + 'certificates': certificates, + 'total_hours': total_hours, + 'completed_trainings': completed_trainings, + } + + return render(request, 'hr/training/employee_transcript.html', context) + + +# =========================== +# Additional Training Record Management Views +# =========================== + +@login_required +def training_record_mark_complete(request, record_id): + """Mark a training record as complete.""" + record = get_object_or_404( + TrainingRecord, + pk=record_id, + employee__tenant=request.user.tenant + ) + + if request.method == 'POST': + # Update record status + record.status = 'COMPLETED' + record.completion_date = timezone.now().date() + record.passed = True + + # Set credits earned if not already set + if not record.credits_earned: + if record.session and record.session.hours_override: + record.credits_earned = record.session.hours_override + elif record.program: + record.credits_earned = record.program.duration_hours + + # Get score from form if provided + score = request.POST.get('score') + if score: + try: + record.score = float(score) + record.passed = record.score >= 70 # Assuming 70% is passing + except (ValueError, TypeError): + pass + + # Add completion notes + notes = request.POST.get('notes', '') + if notes: + existing_notes = record.notes or '' + record.notes = f"{existing_notes}\n\nCompleted: {notes}".strip() + + record.save() + + # Auto-issue certificate if program is certified and employee passed + if (record.program and record.program.is_certified and record.passed and + not TrainingCertificates.objects.filter(enrollment=record).exists()): + + # Calculate expiry date + expiry_date = None + if record.program.validity_days: + expiry_date = record.completion_date + timedelta(days=record.program.validity_days) + + # Generate certificate number + certificate_number = f"CERT-{record.program.tenant.id}-{timezone.now().strftime('%Y%m%d')}-{record.id}" + + # Create certificate + TrainingCertificates.objects.create( + program=record.program, + employee=record.employee, + enrollment=record, + certificate_name=f'{record.program.name} Certificate', + certificate_number=certificate_number, + certification_body='Hospital Training Department', + expiry_date=expiry_date, + created_by=request.user.employee_profile if hasattr(request.user, 'employee_profile') else None + ) + + messages.success(request, f'Training record marked as complete and certificate issued for {record.employee.get_full_name()}.') + else: + messages.success(request, f'Training record marked as complete for {record.employee.get_full_name()}.') + + return redirect('hr:training_record_detail', pk=record.pk) + + context = { + 'record': record, + 'can_issue_certificate': (record.program and record.program.is_certified and + not TrainingCertificates.objects.filter(enrollment=record).exists()) + } + return render(request, 'hr/training/mark_complete.html', context) + + +@login_required +def training_record_renew(request, record_id): + """Renew an expired training record by creating a new enrollment.""" + original_record = get_object_or_404( + TrainingRecord, + pk=record_id, + employee__tenant=request.user.tenant + ) + + if request.method == 'POST': + # Create new training record based on the original + new_record = TrainingRecord.objects.create( + employee=original_record.employee, + program=original_record.program, + session=None, # Will need to be assigned to a new session + status='SCHEDULED', + created_by=request.user + ) + + # Add renewal note + new_record.notes = f"Renewal of training record #{original_record.id} from {original_record.completion_date}" + new_record.save() + + messages.success( + request, + f'Training renewal created for {original_record.employee.get_full_name()}. ' + f'Please assign to an appropriate session.' + ) + + return redirect('hr:training_record_detail', pk=new_record.pk) + + context = { + 'record': original_record, + 'available_sessions': TrainingSession.objects.filter( + program=original_record.program, + start_at__gte=timezone.now() + ).order_by('start_at') + } + return render(request, 'hr/training/renew_record.html', context) + + +@login_required +def training_record_mark_expired(request, record_id): + """Mark a training record as expired.""" + record = get_object_or_404( + TrainingRecord, + pk=record_id, + employee__tenant=request.user.tenant + ) + + if request.method == 'POST': + # Update record status + record.status = 'FAILED' # Using FAILED status to indicate expired/invalid + + # Add expiry note + expiry_reason = request.POST.get('reason', 'Marked as expired') + existing_notes = record.notes or '' + record.notes = f"{existing_notes}\n\nExpired: {expiry_reason} on {timezone.now().date()}".strip() + record.save() + + # Mark related certificate as expired if exists + certificate = TrainingCertificates.objects.filter(enrollment=record).first() + if certificate: + certificate.expiry_date = timezone.now().date() + certificate.save() + + messages.success(request, f'Training record marked as expired for {record.employee.get_full_name()}.') + return redirect('hr:training_record_detail', pk=record.pk) + + context = {'record': record} + return render(request, 'hr/training/mark_expired.html', context) + + +@login_required +def training_record_archive(request, record_id): + """Archive a training record (soft delete).""" + record = get_object_or_404( + TrainingRecord, + pk=record_id, + employee__tenant=request.user.tenant + ) + + if request.method == 'POST': + # Add archive note instead of deleting + archive_reason = request.POST.get('reason', 'Archived by user') + existing_notes = record.notes or '' + record.notes = f"{existing_notes}\n\nArchived: {archive_reason} on {timezone.now().date()}".strip() + + # Change status to indicate archived + record.status = 'CANCELLED' + record.save() + + messages.success(request, f'Training record archived for {record.employee.get_full_name()}.') + return redirect('hr:training_record_list') + + context = {'record': record} + return render(request, 'hr/training/archive_record.html', context) + + +@login_required +def training_record_duplicate(request, record_id): + """Duplicate a training record for the same or different employee.""" + original_record = get_object_or_404( + TrainingRecord, + pk=record_id, + employee__tenant=request.user.tenant + ) + + if request.method == 'POST': + # Get target employee (default to same employee) + employee_id = request.POST.get('employee_id', original_record.employee.id) + target_employee = get_object_or_404( + Employee, + pk=employee_id, + tenant=request.user.tenant + ) + + # Check if employee already has this training + existing_record = TrainingRecord.objects.filter( + employee=target_employee, + program=original_record.program, + status__in=['SCHEDULED', 'IN_PROGRESS', 'COMPLETED'] + ).first() + + if existing_record: + messages.warning( + request, + f'{target_employee.get_full_name()} already has an active record for this training program.' + ) + return redirect('hr:training_record_detail', pk=original_record.pk) + + # Create duplicate record + new_record = TrainingRecord.objects.create( + employee=target_employee, + program=original_record.program, + session=None, # Will need to be assigned to a session + status='SCHEDULED', + created_by=request.user + ) + + # Add duplication note + new_record.notes = f"Duplicated from training record #{original_record.id} for {original_record.employee.get_full_name()}" + new_record.save() + + messages.success( + request, + f'Training record duplicated for {target_employee.get_full_name()}. ' + f'Please assign to an appropriate session.' + ) + + return redirect('hr:training_record_detail', pk=new_record.pk) + + context = { + 'record': original_record, + 'employees': Employee.objects.filter( + tenant=request.user.tenant, + employment_status='ACTIVE' + ).order_by('last_name', 'first_name'), + 'available_sessions': TrainingSession.objects.filter( + program=original_record.program, + start_at__gte=timezone.now() + ).order_by('start_at') + } + return render(request, 'hr/training/duplicate_record.html', context) + + +# ============================================================================ +# AJAX ENDPOINTS FOR DYNAMIC FORM FUNCTIONALITY +# ============================================================================ + +from django.views.decorators.http import require_http_methods + +@require_http_methods(["GET"]) +def get_program_sessions(request): + """ + AJAX endpoint to get sessions for a specific training program. + """ + program_id = request.GET.get('program_id') + if not program_id: + return JsonResponse({'sessions': []}) + + try: + program = TrainingPrograms.objects.get( + id=program_id, + tenant=request.user.tenant + ) + + sessions = TrainingSession.objects.filter( + program=program, + start_at__gte=timezone.now() + ).order_by('start_at') + + sessions_data = [] + for session in sessions: + sessions_data.append({ + 'id': session.id, + 'title': session.title or session.program.name, + 'start_at': session.start_at.strftime('%Y-%m-%d %H:%M'), + 'end_at': session.end_at.strftime('%Y-%m-%d %H:%M'), + 'location': session.location or '', + 'delivery_method': session.get_delivery_method_display(), + 'capacity': session.capacity or 0, + 'enrolled_count': session.enrollments.count() + }) + + return JsonResponse({'sessions': sessions_data}) + + except TrainingPrograms.DoesNotExist: + return JsonResponse({'sessions': []}) + + +@require_http_methods(["GET"]) +def get_program_details(request): + """ + AJAX endpoint to get details for a specific training program. + """ + program_id = request.GET.get('program_id') + if not program_id: + return JsonResponse({'error': 'Program ID required'}, status=400) + + try: + program = TrainingPrograms.objects.get( + id=program_id, + tenant=request.user.tenant + ) + + program_data = { + 'id': program.id, + 'name': program.name, + 'description': program.description, + 'program_type': program.program_type, + 'duration_hours': float(program.duration_hours), + 'cost': float(program.cost), + 'is_certified': program.is_certified, + 'validity_days': program.validity_days, + } + + return JsonResponse(program_data) + + except TrainingPrograms.DoesNotExist: + return JsonResponse({'error': 'Program not found'}, status=404) diff --git a/hr_data.py b/hr_data.py index 07292cf3..0cf22218 100644 --- a/hr_data.py +++ b/hr_data.py @@ -5,14 +5,23 @@ import django # Set up Django environment os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'hospital_management.settings') -django.setup() +try: + django.setup() +except Exception as e: + print(f"Django setup failed: {e}") + print("Please run this script using: python manage.py shell -c \"exec(open('hr_data.py').read())\"") + exit(1) import random from datetime import datetime, timedelta, date, time from decimal import Decimal from django.utils import timezone as django_timezone -from hr.models import Employee, Department, Schedule, ScheduleAssignment, TimeEntry, PerformanceReview, TrainingRecord +from hr.models import ( + Employee, Department, Schedule, ScheduleAssignment, TimeEntry, PerformanceReview, + TrainingPrograms, TrainingSession, TrainingRecord, ProgramModule, ProgramPrerequisite, + TrainingAttendance, TrainingAssessment, TrainingCertificates +) from accounts.models import User from core.models import Tenant @@ -222,8 +231,8 @@ def create_or_update_saudi_employees(tenants, departments_by_tenant, employees_p username=username, email=email, first_name=first, - father_name=father, - grandfather_name=grandfather, + # father_name=father, + # grandfather_name=grandfather, last_name=last, is_active=True, ) @@ -564,8 +573,8 @@ def create_performance_reviews(employees): review_type = random.choices(['ANNUAL', 'PROBATIONARY', 'MID_YEAR'], weights=[60, 20, 20])[0] overall = Decimal(str(random.choices([2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0], weights=[5, 10, 20, 25, 25, 10, 5])[0])) - ratings = {c: Decimal(str(random.choices([2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0], - weights=[5, 10, 20, 25, 25, 10, 5])[0])) + ratings = {c: float(random.choices([2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0], + weights=[5, 10, 20, 25, 25, 10, 5])[0]) for c in random.sample(competency_areas, random.randint(6, 10))} status = random.choices(['COMPLETED', 'ACKNOWLEDGED', 'IN_PROGRESS'], weights=[60, 30, 10])[0] @@ -602,106 +611,566 @@ def create_performance_reviews(employees): return reviews -def create_training_records(employees): - """Create training records for ACTIVE employees (role-aware).""" - records = [] - active = [e for e in employees if e.employment_status == Employee.EmploymentStatus.ACTIVE and e.hire_date] - - training_by_role = { - 'ALL': ['Orientation', 'Fire Safety', 'Emergency Procedures', 'HIPAA Compliance', 'Patient Safety'], - 'CLINICAL': ['Basic Life Support (BLS)', 'Infection Control', 'Medication Administration', - 'Wound Care Management'], - 'PHYSICIAN': ['Advanced Cardiac Life Support (ACLS)', 'Pediatric Advanced Life Support (PALS)'], - 'NURSE': ['IV Therapy', 'Pain Management', 'Electronic Health Records'], - 'ADMIN': ['Customer Service Excellence', 'Quality Improvement', 'Arabic Language'], - 'SUPPORT': ['Safety Training', 'Equipment Operation', 'Cultural Sensitivity'] - } - - for emp in active: - mandatory = training_by_role['ALL'][:] - - dtype = getattr(emp.department, 'department_type', '') if emp.department else '' - if dtype == 'CLINICAL': - mandatory += training_by_role['CLINICAL'] - jt = (emp.job_title or '').lower() - if 'physician' in jt: - mandatory += training_by_role['PHYSICIAN'] - elif 'nurse' in jt: - mandatory += training_by_role['NURSE'] - elif dtype == 'ADMINISTRATIVE': - mandatory += training_by_role['ADMIN'] - else: - mandatory += training_by_role['SUPPORT'] - - all_training = list(set(mandatory + random.sample(SAUDI_TRAINING_PROGRAMS, random.randint(2, 5)))) - days_since_hire = max(1, (django_timezone.now().date() - emp.hire_date).days) - - for tname in all_training: - training_date = emp.hire_date + timedelta(days=random.randint(0, days_since_hire)) - completion_date = training_date + timedelta(days=random.randint(0, 7)) - - if tname == 'Orientation': - ttype = 'ORIENTATION' - elif tname in ['Fire Safety', 'Emergency Procedures', 'HIPAA Compliance', 'Patient Safety']: - ttype = 'MANDATORY' - elif 'Certification' in tname or any(k in tname for k in ['BLS', 'ACLS', 'PALS']): - ttype = 'CERTIFICATION' - elif tname in ['Safety Training', 'Infection Control']: - ttype = 'SAFETY' - else: - ttype = 'CONTINUING_ED' - - if ttype == 'CERTIFICATION': - duration = Decimal(str(random.randint(8, 16))) - expiry_date = completion_date + timedelta(days=random.randint(365, 730)) - elif ttype == 'MANDATORY': - duration = Decimal(str(random.randint(2, 8))) - expiry_date = None - else: - duration = Decimal(str(random.randint(1, 4))) - expiry_date = None - - status = random.choices(['COMPLETED', 'IN_PROGRESS', 'SCHEDULED'], weights=[80, 15, 5])[0] - score = Decimal(str(random.randint(75, 100))) if status == 'COMPLETED' else None - passed = bool(score and score >= 70) - +def create_training_programs(tenants, employees): + """Create training programs for each tenant.""" + programs = [] + + training_data = [ + ('Orientation', 'ORIENTATION', 'New employee orientation program', 8, False, None), + ('Fire Safety', 'SAFETY', 'Fire safety and emergency evacuation procedures', 2, False, None), + ('Emergency Procedures', 'MANDATORY', 'Hospital emergency response procedures', 4, False, None), + ('HIPAA Compliance', 'COMPLIANCE', 'Patient privacy and data protection training', 3, False, None), + ('Patient Safety', 'MANDATORY', 'Patient safety protocols and best practices', 6, False, None), + ('Basic Life Support (BLS)', 'CERTIFICATION', 'Basic life support certification', 8, True, 730), + ('Advanced Cardiac Life Support (ACLS)', 'CERTIFICATION', 'Advanced cardiac life support certification', 16, True, 730), + ('Pediatric Advanced Life Support (PALS)', 'CERTIFICATION', 'Pediatric advanced life support certification', 14, True, 730), + ('Infection Control', 'SAFETY', 'Infection prevention and control measures', 4, False, None), + ('Medication Administration', 'SKILLS', 'Safe medication administration practices', 6, False, None), + ('Wound Care Management', 'SKILLS', 'Advanced wound care techniques', 8, False, None), + ('IV Therapy', 'SKILLS', 'Intravenous therapy administration', 6, False, None), + ('Pain Management', 'CONTINUING_ED', 'Pain assessment and management strategies', 4, False, None), + ('Electronic Health Records', 'TECHNICAL', 'EHR system training and best practices', 4, False, None), + ('Customer Service Excellence', 'OTHER', 'Patient and family service excellence', 3, False, None), + ('Quality Improvement', 'CONTINUING_ED', 'Healthcare quality improvement methodologies', 6, False, None), + ('Arabic Language', 'OTHER', 'Arabic language skills for healthcare', 20, False, None), + ('Cultural Sensitivity', 'OTHER', 'Cultural competency in healthcare', 4, False, None), + ('Islamic Healthcare Ethics', 'MANDATORY', 'Islamic principles in healthcare practice', 6, False, None), + ('Leadership Development', 'LEADERSHIP', 'Leadership skills for healthcare managers', 12, False, None), + ] + + providers = [ + 'Saudi Healthcare Training Institute', + 'Ministry of Health Training Center', + 'King Fahd Medical Training Academy', + 'Internal Training Department', + 'Saudi Commission for Health Specialties' + ] + + # Get potential instructors + potential_instructors = [e for e in employees + if e.employment_status == Employee.EmploymentStatus.ACTIVE + and any(k in (e.job_title or '').lower() for k in ['senior', 'chief', 'manager', 'director', 'physician'])] + + for tenant in tenants: + tenant_instructors = [e for e in potential_instructors if e.tenant == tenant] + + for name, ptype, description, hours, is_certified, validity_days in training_data: + # Set program dates + start_date = django_timezone.now().date() - timedelta(days=random.randint(30, 365)) + end_date = start_date + timedelta(days=random.randint(30, 180)) if random.choice([True, False]) else None + try: - rec = TrainingRecord.objects.create( - employee=emp, - # training_name=tname, - # training_description=f"Comprehensive {tname.lower()} training program", - # training_type=ttype, - # training_provider=random.choice([ - # 'Saudi Healthcare Training Institute', - # 'Ministry of Health Training Center', - # 'King Fahd Medical Training Academy', - # 'Internal Training Department' - # ]), - # instructor=f"Dr. {random.choice(SAUDI_FIRST_NAMES_MALE + SAUDI_FIRST_NAMES_FEMALE)} {random.choice(SAUDI_LAST_NAMES)}", - # training_date=training_date, - completion_date=completion_date if status == 'COMPLETED' else None, - # expiry_date=expiry_date, - # duration_hours=duration, - credits_earned=duration if status == 'COMPLETED' else Decimal('0.00'), - status=status, - score=score, - passed=passed, - # certificate_number=(f"CERT-{random.randint(100000, 999999)}" - # if status == 'COMPLETED' and ttype == 'CERTIFICATION' else None), - # certification_body=('Saudi Healthcare Certification Board' if ttype == 'CERTIFICATION' else None), - # cost=Decimal(str(random.randint(500, 5000))), - notes=(f"{tname} training completed successfully" if status == 'COMPLETED' else f"{tname} training in progress"), - created_at=django_timezone.now() - timedelta(days=random.randint(1, 30)), - updated_at=django_timezone.now() - timedelta(days=random.randint(0, 15)) + # Check if program already exists for this tenant + existing = TrainingPrograms.objects.filter(tenant=tenant, name=name).first() + if existing: + print(f"Training program {name} already exists for {tenant.name}, skipping...") + programs.append(existing) + continue + + program = TrainingPrograms.objects.create( + tenant=tenant, + name=name, + description=description, + program_type=ptype, + program_provider=random.choice(providers), + instructor=random.choice(tenant_instructors) if tenant_instructors and random.choice([True, False]) else None, + start_date=start_date, + end_date=end_date, + duration_hours=Decimal(str(hours)), + cost=Decimal(str(random.randint(500, 5000))), + is_certified=is_certified, + validity_days=validity_days, + notify_before_days=30 if is_certified else None, + created_at=django_timezone.now() - timedelta(days=random.randint(30, 365)), + updated_at=django_timezone.now() - timedelta(days=random.randint(0, 30)) ) - records.append(rec) - except Exception as ex: - print(f"Error creating training record for {emp.get_full_name()}: {ex}") + programs.append(program) + print(f"Created training program: {name} for {tenant.name}") + except Exception as e: + print(f"Error creating training program {name} for {tenant.name}: {e}") + import traceback + traceback.print_exc() + + print(f"Created {len(programs)} training programs") + return programs + +def create_training_sessions(programs, employees): + """Create training sessions for programs.""" + sessions = [] + + # Get instructors (employees who could teach) + potential_instructors = [e for e in employees + if e.employment_status == Employee.EmploymentStatus.ACTIVE + and any(k in (e.job_title or '').lower() for k in ['senior', 'chief', 'manager', 'director', 'physician'])] + + for program in programs: + # Create 1-3 sessions per program + num_sessions = random.randint(1, 3) + + for i in range(num_sessions): + # Schedule sessions in the past and future + days_offset = random.randint(-90, 90) + start_date = django_timezone.now().date() + timedelta(days=days_offset) + + # Session duration based on program hours + session_hours = program.duration_hours + if session_hours <= 4: + # Half day session + start_time = time(9, 0) if random.choice([True, False]) else time(14, 0) + end_time = time(13, 0) if start_time == time(9, 0) else time(18, 0) + else: + # Full day or multi-day + start_time = time(9, 0) + end_time = time(17, 0) + if session_hours > 8: + # Multi-day, adjust end date + days_needed = int(session_hours / 8) + (1 if session_hours % 8 > 0 else 0) + start_date = start_date + + start_datetime = django_timezone.make_aware(datetime.combine(start_date, start_time)) + end_datetime = django_timezone.make_aware(datetime.combine(start_date, end_time)) + + # Adjust for multi-day sessions + if session_hours > 8: + days_needed = int(session_hours / 8) + end_datetime = end_datetime + timedelta(days=days_needed) + + instructor = random.choice(potential_instructors) if potential_instructors else None + + try: + session = TrainingSession.objects.create( + program=program, + title=f"{program.name} - Session {i+1}", + instructor=instructor, + delivery_method=random.choice(['IN_PERSON', 'VIRTUAL', 'HYBRID']), + start_at=start_datetime, + end_at=end_datetime, + location=random.choice([ + 'Training Room A', 'Training Room B', 'Conference Hall', + 'Simulation Lab', 'Skills Lab', 'Auditorium' + ]), + capacity=random.randint(10, 50), + created_at=django_timezone.now() - timedelta(days=random.randint(1, 30)) + ) + sessions.append(session) + except Exception as e: + print(f"Error creating session for {program.name}: {e}") + + print(f"Created {len(sessions)} training sessions") + return sessions + + +def create_training_records(employees, sessions): + """Create training enrollment records for employees.""" + records = [] + + # Define which roles need which training + training_requirements = { + 'ALL': ['Orientation', 'Fire Safety', 'Emergency Procedures', 'HIPAA Compliance', 'Patient Safety'], + 'CLINICAL': ['Basic Life Support (BLS)', 'Infection Control', 'Medication Administration'], + 'PHYSICIAN': ['Advanced Cardiac Life Support (ACLS)', 'Pediatric Advanced Life Support (PALS)'], + 'NURSE': ['IV Therapy', 'Pain Management', 'Wound Care Management'], + 'ADMIN': ['Customer Service Excellence', 'Quality Improvement', 'Arabic Language'], + 'SUPPORT': ['Cultural Sensitivity', 'Customer Service Excellence'] + } + + active_employees = [e for e in employees if e.employment_status == Employee.EmploymentStatus.ACTIVE] + + for emp in active_employees: + # Determine required training based on role and department + required_programs = set(training_requirements['ALL']) + + if emp.department: + dtype = getattr(emp.department, 'department_type', '') + if dtype == 'CLINICAL': + required_programs.update(training_requirements['CLINICAL']) + jt = (emp.job_title or '').lower() + if 'physician' in jt: + required_programs.update(training_requirements['PHYSICIAN']) + elif 'nurse' in jt: + required_programs.update(training_requirements['NURSE']) + elif dtype == 'ADMINISTRATIVE': + required_programs.update(training_requirements['ADMIN']) + else: + required_programs.update(training_requirements['SUPPORT']) + + # Add some random additional training + all_program_names = [s.program.name for s in sessions] + additional_programs = random.sample([p for p in all_program_names if p not in required_programs], + min(3, len([p for p in all_program_names if p not in required_programs]))) + required_programs.update(additional_programs) + + # Find sessions for required programs + for program_name in required_programs: + matching_sessions = [s for s in sessions if s.program.name == program_name and s.program.tenant == emp.tenant] + + if matching_sessions: + session = random.choice(matching_sessions) + + # Determine enrollment status based on session timing + now = django_timezone.now() + if session.start_at < now - timedelta(days=7): + status = random.choices(['COMPLETED', 'NO_SHOW', 'FAILED'], weights=[85, 10, 5])[0] + elif session.start_at < now: + status = random.choices(['IN_PROGRESS', 'COMPLETED'], weights=[30, 70])[0] + else: + status = random.choices(['SCHEDULED', 'WAITLISTED'], weights=[90, 10])[0] + + # Generate scores and completion data + score = None + passed = False + completion_date = None + expiry_date = None + credits_earned = Decimal('0.00') + + if status == 'COMPLETED': + score = Decimal(str(random.randint(70, 100))) + passed = score >= 70 + completion_date = session.end_at.date() + credits_earned = session.program.duration_hours + # Set expiry date for certified programs + if session.program.is_certified and session.program.validity_days: + expiry_date = completion_date + timedelta(days=session.program.validity_days) + elif status == 'FAILED': + score = Decimal(str(random.randint(40, 69))) + passed = False + completion_date = session.end_at.date() + + try: + record = TrainingRecord.objects.create( + employee=emp, + program=session.program, + session=session, + started_at=session.start_at if status in ['IN_PROGRESS', 'COMPLETED', 'FAILED', 'NO_SHOW'] else None, + completion_date=completion_date, + expiry_date=expiry_date, + status=status, + credits_earned=credits_earned, + score=score, + passed=passed, + notes=f"{session.program.name} - {status.replace('_', ' ').title()}", + cost_paid=session.program.cost if random.choice([True, False]) else None, + created_at=django_timezone.now() - timedelta(days=random.randint(1, 30)), + updated_at=django_timezone.now() - timedelta(days=random.randint(0, 15)) + ) + records.append(record) + except Exception as e: + print(f"Error creating training record for {emp.get_full_name()}: {e}") + print(f"Created {len(records)} training records") return records +def create_program_modules(programs): + """Create modules for training programs.""" + modules = [] + + # Define module templates for different program types + module_templates = { + 'ORIENTATION': [ + 'Welcome and Introduction', + 'Company Policies and Procedures', + 'Safety and Security', + 'Benefits and HR Information', + 'Department Overview', + 'Q&A Session' + ], + 'BLS': [ + 'Basic Life Support Overview', + 'CPR Techniques', + 'AED Usage', + 'Choking Response', + 'Hands-on Practice', + 'Assessment and Certification' + ], + 'ACLS': [ + 'Advanced Cardiac Life Support Overview', + 'Cardiac Arrest Management', + 'Arrhythmia Recognition', + 'Pharmacology', + 'Team Dynamics', + 'Simulation Scenarios', + 'Written and Practical Exam' + ], + 'INFECTION_CONTROL': [ + 'Infection Prevention Principles', + 'Hand Hygiene', + 'Personal Protective Equipment', + 'Isolation Precautions', + 'Cleaning and Disinfection' + ] + } + + for program in programs: + # Determine modules based on program name/type + if 'BLS' in program.name: + module_list = module_templates['BLS'] + elif 'ACLS' in program.name: + module_list = module_templates['ACLS'] + elif 'Orientation' in program.name: + module_list = module_templates['ORIENTATION'] + elif 'Infection Control' in program.name: + module_list = module_templates['INFECTION_CONTROL'] + else: + # Generic modules for other programs + module_list = [ + 'Introduction and Overview', + 'Core Concepts', + 'Practical Application', + 'Assessment' + ] + + # Create modules for this program + total_hours = float(program.duration_hours) + hours_per_module = total_hours / len(module_list) + + for order, title in enumerate(module_list, 1): + try: + module = ProgramModule.objects.create( + program=program, + title=title, + order=order, + hours=Decimal(str(round(hours_per_module, 2))) + ) + modules.append(module) + except Exception as e: + print(f"Error creating module {title} for {program.name}: {e}") + + print(f"Created {len(modules)} program modules") + return modules + + +def create_program_prerequisites(programs): + """Create prerequisites between training programs.""" + prerequisites = [] + + # Define prerequisite relationships + prerequisite_rules = { + 'Advanced Cardiac Life Support (ACLS)': ['Basic Life Support (BLS)'], + 'Pediatric Advanced Life Support (PALS)': ['Basic Life Support (BLS)'], + 'Wound Care Management': ['Infection Control'], + 'IV Therapy': ['Medication Administration'], + 'Pain Management': ['Medication Administration'], + 'Leadership Development': ['Customer Service Excellence'] + } + + # Group programs by tenant for easier lookup + programs_by_tenant = {} + for program in programs: + if program.tenant not in programs_by_tenant: + programs_by_tenant[program.tenant] = {} + programs_by_tenant[program.tenant][program.name] = program + + for tenant, tenant_programs in programs_by_tenant.items(): + for program_name, required_program_names in prerequisite_rules.items(): + if program_name in tenant_programs: + program = tenant_programs[program_name] + + for required_name in required_program_names: + if required_name in tenant_programs: + required_program = tenant_programs[required_name] + + try: + prerequisite = ProgramPrerequisite.objects.create( + program=program, + required_program=required_program + ) + prerequisites.append(prerequisite) + except Exception as e: + print(f"Error creating prerequisite {required_name} -> {program_name}: {e}") + + print(f"Created {len(prerequisites)} program prerequisites") + return prerequisites + + +def create_training_attendance(training_records): + """Create attendance records for training sessions.""" + attendance_records = [] + + # Only create attendance for records that have started + started_records = [r for r in training_records if r.started_at and r.status in ['IN_PROGRESS', 'COMPLETED', 'FAILED', 'NO_SHOW']] + + for record in started_records: + # Determine attendance status based on training record status + if record.status == 'NO_SHOW': + attendance_status = 'ABSENT' + elif record.status == 'COMPLETED': + attendance_status = random.choices(['PRESENT', 'LATE'], weights=[90, 10])[0] + elif record.status == 'FAILED': + attendance_status = random.choices(['PRESENT', 'LATE', 'ABSENT'], weights=[70, 20, 10])[0] + else: # IN_PROGRESS + attendance_status = random.choices(['PRESENT', 'LATE'], weights=[85, 15])[0] + + # Set check-in/out times based on session times and attendance status + session = record.session + checked_in_at = None + checked_out_at = None + + if attendance_status in ['PRESENT', 'LATE']: + if attendance_status == 'LATE': + # Late arrival (5-30 minutes after start) + late_minutes = random.randint(5, 30) + checked_in_at = session.start_at + timedelta(minutes=late_minutes) + else: + # On time or early arrival + early_minutes = random.randint(-10, 5) + checked_in_at = session.start_at + timedelta(minutes=early_minutes) + + # Check out time (if session is completed) + if record.status in ['COMPLETED', 'FAILED']: + checkout_variance = random.randint(-15, 15) + checked_out_at = session.end_at + timedelta(minutes=checkout_variance) + + notes = None + if attendance_status == 'LATE': + notes = f"Arrived {late_minutes} minutes late" + elif attendance_status == 'ABSENT': + notes = "Did not attend session" + elif attendance_status == 'EXCUSED': + notes = "Excused absence" + + try: + attendance = TrainingAttendance.objects.create( + enrollment=record, + checked_in_at=checked_in_at, + checked_out_at=checked_out_at, + status=attendance_status, + notes=notes + ) + attendance_records.append(attendance) + except Exception as e: + print(f"Error creating attendance for {record.employee.get_full_name()}: {e}") + + print(f"Created {len(attendance_records)} attendance records") + return attendance_records + + +def create_training_assessments(training_records): + """Create assessments for training records.""" + assessments = [] + + # Only create assessments for completed or failed records + assessed_records = [r for r in training_records if r.status in ['COMPLETED', 'FAILED'] and r.score is not None] + + assessment_types = { + 'CERTIFICATION': ['Written Exam', 'Practical Assessment'], + 'SKILLS': ['Practical Assessment', 'Skills Demonstration'], + 'MANDATORY': ['Knowledge Check'], + 'SAFETY': ['Safety Quiz'], + 'COMPLIANCE': ['Compliance Test'], + 'OTHER': ['Final Assessment'] + } + + for record in assessed_records: + program_type = record.program.program_type + possible_assessments = assessment_types.get(program_type, ['Final Assessment']) + + # Create 1-2 assessments per record + num_assessments = random.randint(1, min(2, len(possible_assessments))) + selected_assessments = random.sample(possible_assessments, num_assessments) + + for assessment_name in selected_assessments: + # Determine max score based on assessment type + if 'Practical' in assessment_name: + max_score = Decimal('100.00') + elif 'Written' in assessment_name or 'Exam' in assessment_name: + max_score = Decimal('100.00') + else: + max_score = Decimal('50.00') + + # Generate score based on training record score + base_score = float(record.score) if record.score else 70 + # Add some variance to the assessment score + score_variance = random.randint(-10, 10) + assessment_score = max(0, min(float(max_score), base_score + score_variance)) + + passed = assessment_score >= (float(max_score) * 0.7) # 70% passing + + # Set taken date around the completion date + if record.completion_date: + taken_at = django_timezone.make_aware( + datetime.combine(record.completion_date, time(random.randint(9, 17), random.randint(0, 59))) + ) + else: + taken_at = record.session.end_at + + try: + assessment = TrainingAssessment.objects.create( + enrollment=record, + name=assessment_name, + max_score=max_score, + score=Decimal(str(assessment_score)), + passed=passed, + taken_at=taken_at, + notes=f"{assessment_name} for {record.program.name}" + ) + assessments.append(assessment) + except Exception as e: + print(f"Error creating assessment for {record.employee.get_full_name()}: {e}") + + print(f"Created {len(assessments)} training assessments") + return assessments + + +def create_training_certificates(training_records, employees): + """Create certificates for completed certified training.""" + certificates = [] + + # Only create certificates for completed certified programs + eligible_records = [r for r in training_records + if r.status == 'COMPLETED' and r.passed and r.program.is_certified] + + # Get potential signers (senior staff) + potential_signers = [e for e in employees + if e.employment_status == Employee.EmploymentStatus.ACTIVE + and any(k in (e.job_title or '').lower() for k in ['chief', 'director', 'manager'])] + + for record in eligible_records: + # Generate certificate details + certificate_name = f"{record.program.name} Certificate" + certificate_number = f"CERT-{record.program.tenant.id}-{random.randint(100000, 999999)}" + + # Determine certification body based on program type + if 'BLS' in record.program.name or 'ACLS' in record.program.name or 'PALS' in record.program.name: + certification_body = 'Saudi Heart Association' + elif record.program.program_type == 'SAFETY': + certification_body = 'Saudi Occupational Safety and Health Administration' + elif record.program.program_type == 'COMPLIANCE': + certification_body = 'Saudi Ministry of Health' + else: + certification_body = 'Saudi Commission for Health Specialties' + + # Set expiry date + expiry_date = None + if record.program.validity_days: + expiry_date = record.completion_date + timedelta(days=record.program.validity_days) + + # Select signer from same tenant + tenant_signers = [s for s in potential_signers if s.tenant == record.employee.tenant] + signer = random.choice(tenant_signers) if tenant_signers else None + + try: + certificate = TrainingCertificates.objects.create( + program=record.program, + employee=record.employee, + enrollment=record, + certificate_name=certificate_name, + certificate_number=certificate_number, + certification_body=certification_body, + expiry_date=expiry_date, + signed_by=signer, + created_at=django_timezone.now() - timedelta(days=random.randint(0, 7)), + updated_at=django_timezone.now() - timedelta(days=random.randint(0, 3)) + ) + certificates.append(certificate) + except Exception as e: + print(f"Error creating certificate for {record.employee.get_full_name()}: {e}") + + print(f"Created {len(certificates)} training certificates") + return certificates + + # ---------------------------- # Orchestration # ---------------------------- @@ -772,9 +1241,37 @@ def main(): print("\n8. Creating Performance Reviews...") reviews = create_performance_reviews(employees) - # 9) Training records - print("\n9. Creating Training Records...") - # training_records = create_training_records(employees) + # 9) Training programs + print("\n9. Creating Training Programs...") + training_programs = create_training_programs(tenants, employees) + + # 10) Program modules + print("\n10. Creating Program Modules...") + program_modules = create_program_modules(training_programs) + + # 11) Program prerequisites + print("\n11. Creating Program Prerequisites...") + program_prerequisites = create_program_prerequisites(training_programs) + + # 12) Training sessions + print("\n12. Creating Training Sessions...") + training_sessions = create_training_sessions(training_programs, employees) + + # 13) Training records + print("\n13. Creating Training Records...") + training_records = create_training_records(employees, training_sessions) + + # 14) Training attendance + print("\n14. Creating Training Attendance...") + training_attendance = create_training_attendance(training_records) + + # 15) Training assessments + print("\n15. Creating Training Assessments...") + training_assessments = create_training_assessments(training_records) + + # 16) Training certificates + print("\n16. Creating Training Certificates...") + training_certificates = create_training_certificates(training_records, employees) print(f"\n✅ Saudi Healthcare HR Data Generation Complete!") print(f"📊 Summary:") @@ -785,7 +1282,14 @@ def main(): print(f" - Schedule Assignments: {len(assignments)}") print(f" - Time Entries: {len(time_entries)}") print(f" - Performance Reviews: {len(reviews)}") - # print(f" - Training Records: {len(training_records)}") + print(f" - Training Programs: {len(training_programs)}") + print(f" - Program Modules: {len(program_modules)}") + print(f" - Program Prerequisites: {len(program_prerequisites)}") + print(f" - Training Sessions: {len(training_sessions)}") + print(f" - Training Records: {len(training_records)}") + print(f" - Training Attendance: {len(training_attendance)}") + print(f" - Training Assessments: {len(training_assessments)}") + print(f" - Training Certificates: {len(training_certificates)}") # Distribution summaries dept_counts = {} @@ -813,9 +1317,16 @@ def main(): 'assignments': assignments, 'time_entries': time_entries, 'reviews': reviews, - # 'training_records': training_records, + 'training_programs': training_programs, + 'program_modules': program_modules, + 'program_prerequisites': program_prerequisites, + 'training_sessions': training_sessions, + 'training_records': training_records, + 'training_attendance': training_attendance, + 'training_assessments': training_assessments, + 'training_certificates': training_certificates, } if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/inpatients/__pycache__/models.cpython-312.pyc b/inpatients/__pycache__/models.cpython-312.pyc index 03b034c1..bcce80eb 100644 Binary files a/inpatients/__pycache__/models.cpython-312.pyc and b/inpatients/__pycache__/models.cpython-312.pyc differ diff --git a/inpatients/__pycache__/urls.cpython-312.pyc b/inpatients/__pycache__/urls.cpython-312.pyc index 6276eb09..5f9f10c7 100644 Binary files a/inpatients/__pycache__/urls.cpython-312.pyc and b/inpatients/__pycache__/urls.cpython-312.pyc differ diff --git a/inpatients/__pycache__/views.cpython-312.pyc b/inpatients/__pycache__/views.cpython-312.pyc index 14bcebcd..f0f97b68 100644 Binary files a/inpatients/__pycache__/views.cpython-312.pyc and b/inpatients/__pycache__/views.cpython-312.pyc differ diff --git a/inpatients/migrations/0001_initial.py b/inpatients/migrations/0001_initial.py index 0ac59e40..eb985676 100644 --- a/inpatients/migrations/0001_initial.py +++ b/inpatients/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-09-19 10:58 +# Generated by Django 5.2.6 on 2025-09-26 18:33 import django.core.validators import django.db.models.deletion @@ -83,6 +83,7 @@ class Migration(migrations.Migration): ("COMPLETED", "Completed"), ("CANCELLED", "Cancelled"), ("DELAYED", "Delayed"), + ("REJECTED", "Rejected"), ], default="REQUESTED", help_text="Transfer status", diff --git a/inpatients/migrations/0002_initial.py b/inpatients/migrations/0002_initial.py index e8a334f5..22666b77 100644 --- a/inpatients/migrations/0002_initial.py +++ b/inpatients/migrations/0002_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-09-19 10:58 +# Generated by Django 5.2.6 on 2025-09-26 18:33 import django.db.models.deletion from django.conf import settings diff --git a/inpatients/migrations/__pycache__/0001_initial.cpython-312.pyc b/inpatients/migrations/__pycache__/0001_initial.cpython-312.pyc index 37ead290..c680fb53 100644 Binary files a/inpatients/migrations/__pycache__/0001_initial.cpython-312.pyc and b/inpatients/migrations/__pycache__/0001_initial.cpython-312.pyc differ diff --git a/inpatients/migrations/__pycache__/0002_initial.cpython-312.pyc b/inpatients/migrations/__pycache__/0002_initial.cpython-312.pyc index 7a640c2f..33d43bcf 100644 Binary files a/inpatients/migrations/__pycache__/0002_initial.cpython-312.pyc and b/inpatients/migrations/__pycache__/0002_initial.cpython-312.pyc differ diff --git a/inpatients/models.py b/inpatients/models.py index 4107edb0..5230338a 100644 --- a/inpatients/models.py +++ b/inpatients/models.py @@ -866,38 +866,38 @@ class DischargeSummary(models.Model): """ Discharge summary model for documenting patient discharge. """ - DISCHARGE_DISPOSITION_CHOICES = [ - ('HOME', 'Home'), - ('HOME_HEALTH', 'Home with Health Services'), - ('NURSING_HOME', 'Nursing Home'), - ('REHAB_FACILITY', 'Rehabilitation Facility'), - ('HOSPICE', 'Hospice'), - ('TRANSFER', 'Transfer to Another Hospital'), - ('MORGUE', 'Morgue'), - ('OTHER', 'Other'), - ] - TRANSPORTATION_METHOD_CHOICES = [ - ('PRIVATE', 'Private Vehicle'), - ('TAXI', 'Taxi'), - ('AMBULANCE', 'Ambulance'), - ('MEDICAL_TRANSPORT', 'Medical Transport'), - ('PUBLIC_TRANSPORT', 'Public Transportation'), - ('WALKING', 'Walking'), - ('OTHER', 'Other'), - ] - PATIENT_UNDERSTANDING_CHOICES = [ - ('EXCELLENT', 'Excellent'), - ('GOOD', 'Good'), - ('FAIR', 'Fair'), - ('POOR', 'Poor'), - ] - READMISSION_RISK_CHOICES=[ - ('LOW', 'Low Risk'), - ('MODERATE', 'Moderate Risk'), - ('HIGH', 'High Risk'), - ('VERY_HIGH', 'Very High Risk'), - ] + class DischargeDisposition(models.TextChoices): + HOME = 'HOME', 'Home' + HOME_HEALTH = 'HOME_HEALTH', 'Home with Health Services' + NURSING_HOME = 'NURSING_HOME', 'Nursing Home' + REHAB_FACILITY = 'REHAB_FACILITY', 'Rehabilitation Facility' + HOSPICE = 'HOSPICE', 'Hospice' + TRANSFER = 'TRANSFER', 'Transfer to Another Hospital' + MORGUE = 'MORGUE', 'Morgue' + OTHER = 'OTHER', 'Other' + + class TransportationMethod(models.TextChoices): + PRIVATE = 'PRIVATE', 'Private Vehicle' + TAXI = 'TAXI', 'Taxi' + AMBULANCE = 'AMBULANCE', 'Ambulance' + MEDICAL_TRANSPORT = 'MEDICAL_TRANSPORT', 'Medical Transport' + PUBLIC_TRANSPORT = 'PUBLIC_TRANSPORT', 'Public Transportation' + WALKING = 'WALKING', 'Walking' + OTHER = 'OTHER', 'Other' + + class PatientUnderstanding(models.TextChoices): + EXCELLENT = 'EXCELLENT', 'Excellent' + GOOD = 'GOOD', 'Good' + FAIR = 'FAIR', 'Fair' + POOR = 'POOR', 'Poor' + + class ReadmissionRisk(models.TextChoices): + LOW = 'LOW', 'Low Risk' + MODERATE = 'MODERATE', 'Moderate Risk' + HIGH = 'HIGH', 'High Risk' + VERY_HIGH = 'VERY_HIGH', 'Very High Risk' + # Admission relationship admission = models.OneToOneField( Admission, @@ -1014,7 +1014,7 @@ class DischargeSummary(models.Model): # Discharge Disposition discharge_disposition = models.CharField( max_length=30, - choices=DISCHARGE_DISPOSITION_CHOICES, + choices=DischargeDisposition.choices, help_text='Discharge disposition' ) discharge_location = models.CharField( @@ -1031,7 +1031,7 @@ class DischargeSummary(models.Model): ) transportation_method = models.CharField( max_length=30, - choices=TRANSPORTATION_METHOD_CHOICES, + choices=TransportationMethod.choices, blank=True, null=True, help_text='Method of transportation' @@ -1062,7 +1062,7 @@ class DischargeSummary(models.Model): ) patient_understanding = models.CharField( max_length=20, - choices=PATIENT_UNDERSTANDING_CHOICES, + choices=PatientUnderstanding.choices, blank=True, null=True, help_text='Patient understanding of instructions' @@ -1089,7 +1089,7 @@ class DischargeSummary(models.Model): # Quality Measures readmission_risk = models.CharField( max_length=20, - choices=READMISSION_RISK_CHOICES, + choices=ReadmissionRisk.choices, blank=True, null=True, help_text='Risk of readmission' @@ -1162,44 +1162,45 @@ class Transfer(models.Model): """ Patient transfer model for tracking ward/bed changes. """ - TRANSFER_TYPE_CHOICES = [ - ('WARD', 'Ward Transfer'), - ('BED', 'Bed Transfer'), - ('ROOM', 'Room Transfer'), - ('UNIT', 'Unit Transfer'), - ('FACILITY', 'Facility Transfer'), - ] - STATUS_CHOICES = [ - ('REQUESTED', 'Requested'), - ('APPROVED', 'Approved'), - ('SCHEDULED', 'Scheduled'), - ('IN_PROGRESS', 'In Progress'), - ('COMPLETED', 'Completed'), - ('CANCELLED', 'Cancelled'), - ('DELAYED', 'Delayed'), - ('REJECTED', 'Rejected'), - ] - PRIORITY_CHOICES = [ - ('ROUTINE', 'Routine'), - ('URGENT', 'Urgent'), - ('EMERGENT', 'Emergent'), - ('STAT', 'STAT'), - ] - TRANSPORT_METHOD_CHOICES = [ - ('WHEELCHAIR', 'Wheelchair'), - ('STRETCHER', 'Stretcher'), - ('BED', 'Hospital Bed'), - ('AMBULATORY', 'Walking'), - ('AMBULANCE', 'Ambulance'), - ('OTHER', 'Other'), - ] - PATIENT_CONDITION_CHOICES = [ - ('STABLE', 'Stable'), - ('UNSTABLE', 'Unstable'), - ('CRITICAL', 'Critical'), - ('IMPROVING', 'Improving'), - ('DETERIORATING', 'Deteriorating'), - ] + + class TransferType(models.TextChoices): + WARD = 'WARD', 'Ward Transfer' + BED = 'BED', 'Bed Transfer' + ROOM = 'ROOM', 'Room Transfer' + UNIT = 'UNIT', 'Unit Transfer' + FACILITY = 'FACILITY', 'Facility Transfer' + + class TransferStatus(models.TextChoices): + REQUESTED = 'REQUESTED', 'Requested' + APPROVED = 'APPROVED', 'Approved' + SCHEDULED = 'SCHEDULED', 'Scheduled' + IN_PROGRESS = 'IN_PROGRESS', 'In Progress' + COMPLETED = 'COMPLETED', 'Completed' + CANCELLED = 'CANCELLED', 'Cancelled' + DELAYED = 'DELAYED', 'Delayed' + REJECTED = 'REJECTED', 'Rejected' + + class TransferPriority(models.TextChoices): + ROUTINE = 'ROUTINE', 'Routine' + URGENT = 'URGENT', 'Urgent' + EMERGENT = 'EMERGENT', 'Emergent' + STAT = 'STAT', 'STAT' + + class TransportMethod(models.TextChoices): + WHEELCHAIR = 'WHEELCHAIR', 'Wheelchair' + STRETCHER = 'STRETCHER', 'Stretcher' + BED = 'BED', 'Hospital Bed' + AMBULATORY = 'AMBULATORY', 'Walking' + AMBULANCE = 'AMBULANCE', 'Ambulance' + OTHER = 'OTHER', 'Other' + + # Named to avoid collision with any general PatientCondition enum you may already have + class TransferPatientCondition(models.TextChoices): + STABLE = 'STABLE', 'Stable' + UNSTABLE = 'UNSTABLE', 'Unstable' + CRITICAL = 'CRITICAL', 'Critical' + IMPROVING = 'IMPROVING', 'Improving' + DETERIORATING = 'DETERIORATING', 'Deteriorating' # Transfer Information transfer_id = models.UUIDField( @@ -1226,7 +1227,7 @@ class Transfer(models.Model): # Transfer Details transfer_type = models.CharField( max_length=20, - choices=TRANSFER_TYPE_CHOICES, + choices=TransferType.choices, help_text='Type of transfer' ) @@ -1281,8 +1282,8 @@ class Transfer(models.Model): # Transfer Status status = models.CharField( max_length=20, - choices=STATUS_CHOICES, - default='REQUESTED', + choices=TransferStatus.choices, + default=TransferStatus.REQUESTED, help_text='Transfer status' ) @@ -1292,8 +1293,8 @@ class Transfer(models.Model): ) priority = models.CharField( max_length=20, - choices=PRIORITY_CHOICES, - default='ROUTINE', + choices=TransferPriority.choices, + default=TransferPriority.ROUTINE, help_text='Transfer priority' ) @@ -1324,7 +1325,7 @@ class Transfer(models.Model): # Transport Information transport_method = models.CharField( max_length=20, - choices=TRANSPORT_METHOD_CHOICES, + choices=TransportMethod.choices, blank=True, null=True, help_text='Method of transport' @@ -1351,7 +1352,7 @@ class Transfer(models.Model): # Clinical Information patient_condition = models.CharField( max_length=20, - choices=PATIENT_CONDITION_CHOICES, + choices=TransferPatientCondition.choices, help_text='Patient condition at time of transfer' ) vital_signs = models.JSONField( @@ -1425,52 +1426,53 @@ class SurgerySchedule(models.Model): """ Surgery schedule model for tracking surgical procedures. """ - SURGERY_TYPE_CHOICES = [ - ('ELECTIVE', 'Elective'), - ('URGENT', 'Urgent'), - ('EMERGENT', 'Emergent'), - ('TRAUMA', 'Trauma'), - ('TRANSPLANT', 'Transplant'), - ('CARDIAC', 'Cardiac'), - ('NEUROSURGERY', 'Neurosurgery'), - ('ORTHOPEDIC', 'Orthopedic'), - ('GENERAL', 'General Surgery'), - ('OTHER', 'Other'), - ] - ANESTHESIA_TYPE_CHOICES = [ - ('GENERAL', 'General Anesthesia'), - ('REGIONAL', 'Regional Anesthesia'), - ('LOCAL', 'Local Anesthesia'), - ('SPINAL', 'Spinal Anesthesia'), - ('EPIDURAL', 'Epidural Anesthesia'), - ('MAC', 'Monitored Anesthesia Care'), - ('SEDATION', 'Conscious Sedation'), - ('OTHER', 'Other'), - ] - STATUS_CHOICES = [ - ('SCHEDULED', 'Scheduled'), - ('CONFIRMED', 'Confirmed'), - ('PREP', 'Pre-operative Prep'), - ('IN_PROGRESS', 'In Progress'), - ('COMPLETED', 'Completed'), - ('CANCELLED', 'Cancelled'), - ('POSTPONED', 'Postponed'), - ('DELAYED', 'Delayed'), - ] - RECOVERY_LOCATION_CHOICES = [ - ('PACU', 'Post-Anesthesia Care Unit'), - ('ICU', 'Intensive Care Unit'), - ('WARD', 'Regular Ward'), - ('SAME_DAY', 'Same Day Surgery'), - ('HOME', 'Home'), - ('OTHER', 'Other'), - ] - PRIORITY_CHOICES = [ - ('ROUTINE', 'Routine'), - ('URGENT', 'Urgent'), - ('EMERGENT', 'Emergent'), - ('STAT', 'STAT'), - ] + + class SurgeryType(models.TextChoices): + ELECTIVE = 'ELECTIVE', 'Elective' + URGENT = 'URGENT', 'Urgent' + EMERGENT = 'EMERGENT', 'Emergent' + TRAUMA = 'TRAUMA', 'Trauma' + TRANSPLANT = 'TRANSPLANT', 'Transplant' + CARDIAC = 'CARDIAC', 'Cardiac' + NEUROSURGERY = 'NEUROSURGERY', 'Neurosurgery' + ORTHOPEDIC = 'ORTHOPEDIC', 'Orthopedic' + GENERAL = 'GENERAL', 'General Surgery' + OTHER = 'OTHER', 'Other' + + class SurgeryAnesthesiaType(models.TextChoices): + GENERAL = 'GENERAL', 'General Anesthesia' + REGIONAL = 'REGIONAL', 'Regional Anesthesia' + LOCAL = 'LOCAL', 'Local Anesthesia' + SPINAL = 'SPINAL', 'Spinal Anesthesia' + EPIDURAL = 'EPIDURAL', 'Epidural Anesthesia' + MAC = 'MAC', 'Monitored Anesthesia Care' + SEDATION = 'SEDATION', 'Conscious Sedation' + OTHER = 'OTHER', 'Other' + + class SurgeryStatus(models.TextChoices): + SCHEDULED = 'SCHEDULED', 'Scheduled' + CONFIRMED = 'CONFIRMED', 'Confirmed' + PREP = 'PREP', 'Pre-operative Prep' + IN_PROGRESS = 'IN_PROGRESS', 'In Progress' + COMPLETED = 'COMPLETED', 'Completed' + CANCELLED = 'CANCELLED', 'Cancelled' + POSTPONED = 'POSTPONED', 'Postponed' + DELAYED = 'DELAYED', 'Delayed' + + class RecoveryLocation(models.TextChoices): + PACU = 'PACU', 'Post-Anesthesia Care Unit' + ICU = 'ICU', 'Intensive Care Unit' + WARD = 'WARD', 'Regular Ward' + SAME_DAY = 'SAME_DAY', 'Same Day Surgery' + HOME = 'HOME', 'Home' + OTHER = 'OTHER', 'Other' + + class SurgeryPriority(models.TextChoices): + ROUTINE = 'ROUTINE', 'Routine' + URGENT = 'URGENT', 'Urgent' + EMERGENT = 'EMERGENT', 'Emergent' + STAT = 'STAT', 'STAT' + # Tenant relationship tenant = models.ForeignKey( 'core.Tenant', @@ -1514,7 +1516,7 @@ class SurgerySchedule(models.Model): ) surgery_type = models.CharField( max_length=30, - choices=SURGERY_TYPE_CHOICES, + choices=SurgeryType.choices, help_text='Type of surgery' ) @@ -1587,7 +1589,7 @@ class SurgerySchedule(models.Model): # Anesthesia Information anesthesia_type = models.CharField( max_length=30, - choices=ANESTHESIA_TYPE_CHOICES, + choices=SurgeryAnesthesiaType.choices, blank=True, null=True, help_text='Type of anesthesia' @@ -1632,8 +1634,8 @@ class SurgerySchedule(models.Model): # Surgery Status status = models.CharField( max_length=20, - choices=STATUS_CHOICES, - default='SCHEDULED', + choices=SurgeryStatus.choices, + default=SurgeryStatus.SCHEDULED, help_text='Surgery status' ) @@ -1674,7 +1676,7 @@ class SurgerySchedule(models.Model): # Recovery Information recovery_location = models.CharField( max_length=50, - choices=RECOVERY_LOCATION_CHOICES, + choices=RecoveryLocation.choices, blank=True, null=True, help_text='Post-operative recovery location' @@ -1683,8 +1685,8 @@ class SurgerySchedule(models.Model): # Priority and Urgency priority = models.CharField( max_length=20, - choices=PRIORITY_CHOICES, - default='ROUTINE', + choices=SurgeryPriority.choices, + default=SurgeryPriority.ROUTINE, help_text='Surgery priority' ) diff --git a/inpatients/templates/inpatients/beds/bed_management.html b/inpatients/templates/inpatients/beds/bed_management.html index 645418b7..44473294 100644 --- a/inpatients/templates/inpatients/beds/bed_management.html +++ b/inpatients/templates/inpatients/beds/bed_management.html @@ -22,63 +22,16 @@ -
- -
-
-
-
-

TOTAL BEDS

-

{{ total_beds }}

-
- +
+ +
+
+ Loading statistics...
- - -
-
-
-
-

AVAILABLE BEDS

-

{{ available_beds }}

-
- -
-
- - -
-
-
-
-

OCCUPIED BEDS

-

{{ occupied_beds }}

-
- -
-
- - -
-
-
-
-

MAINTENANCE

-

{{ maintenance_beds }}

-
- -
-
-
diff --git a/inpatients/templates/inpatients/dashboard.html b/inpatients/templates/inpatients/dashboard.html index 0152886a..50a146d9 100644 --- a/inpatients/templates/inpatients/dashboard.html +++ b/inpatients/templates/inpatients/dashboard.html @@ -6,17 +6,16 @@ {% block content %}
-
+
-

- Inpatient Management -

- + + +

+ InpatientManagement +

+

Manage Wards, Beds, Admissions, and more ...

+ +
-
+
{% include 'inpatients/partials/ward_stats.html' %}
@@ -54,47 +53,46 @@
-
-
-
-
- Bed Occupancy Overview -
-
- - - Manage - -
-
-
-
-
- {% include 'inpatients/partials/bed_grid.html' %} -
-
-
+{#
#} +{#
#} +{#
#} +{#
#} +{# Bed Occupancy Overview#} +{#
#} +{#
#} +{# #} +{# #} +{# Manage#} +{# #} +{#
#} +{#
#} +{#
#} +{#
#} +{#
#} +{# {% include 'inpatients/partials/bed_grid.html' %}#} +{#
#} +{#
#} +{#
#} -
-
-
-
- Recent Admissions -
- +
@@ -152,6 +150,7 @@ @@ -121,7 +113,7 @@ diff --git a/laboratory/templates/laboratory/partials/critical_results.html b/laboratory/templates/laboratory/partials/critical_results.html new file mode 100644 index 00000000..890c5a36 --- /dev/null +++ b/laboratory/templates/laboratory/partials/critical_results.html @@ -0,0 +1,64 @@ +{% load static %} + +
+
Test: - - {{ lab_order.test.test_name }} + + {{ lab_order.tests.first.test_name }} -
{{ lab_order.test.test_code }} +
{{ lab_order.tests.first.test_code }}
Status: - - {{ lab_order.get_status_display }} + + {{ lab_order.get_status_display|capfirst }}
Priority: - {{ lab_order.get_priority_display }} + {{ lab_order.get_priority_display|capfirst }}
+ + + + + + + + + + + + {% for result in critical_results %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
PatientTestResultStatusResult DateActions
+
{{ result.order.patient.get_full_name }}
+
{{ result.order.patient.patient_id }}
+
+
{{ result.test.name }}
+
{{ result.test.code }}
+
+
{{ result.value }} {{ result.unit }}
+
Ref: {{ result.reference_range|default:"--" }}
+
+ + {{ result.get_status_display }} + + +
{{ result.result_date|date:"M d, Y H:i" }}
+
+
+ + View + + {% if result.status == 'PENDING' %} + + {% endif %} +
+
+ +
No critical results requiring attention
+
+
diff --git a/laboratory/templates/laboratory/partials/result_list.html b/laboratory/templates/laboratory/partials/result_list.html new file mode 100644 index 00000000..9a3b4e1e --- /dev/null +++ b/laboratory/templates/laboratory/partials/result_list.html @@ -0,0 +1,106 @@ +{% load static %} + + +
+ + + + + + + + + + + + + + + + + {% for result in object_list %} + + + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
+
+ +
+
PatientTestResultReference RangeFlagStatusResult DateTechnologistActions
+
+ +
+
+
+
+
{{ result.order.patient.get_full_name }}
+
{{ result.order.patient.mrn }}
+
{{ result.order.patient.date_of_birth|date:"M d, Y" }}
+
+
+
+
{{ result.test.name }}
+
{{ result.test.code }}
+
Order: {{ result.order.order_number }}
+
+
+ {{ result.value }} +
+
{{ result.unit }}
+
+
{{ result.reference_range|default:"--" }}
+
+ + {{ result.get_abnormal_flag_display|capfirst }} + + + {{ result.get_status_display }} + + {% if result.status == 'PENDING' and result.critical_flag == 'CRITICAL' %} +
+ Needs Review +
+ {% endif %} +
+
{{ result.result_date|date:"M d, Y" }}
+
{{ result.result_date|time:"H:i" }}
+
+
{{ result.technologist.get_full_name }}
+ {% if result.verified_by %} +
Verified by: {{ result.verified_by.get_full_name }}
+ {% endif %} +
+
+ + + + {% if result.status == 'PENDING' %} + + {% endif %} +
+
+ +
No lab results found
+
+
diff --git a/laboratory/templates/laboratory/partials/result_row.html b/laboratory/templates/laboratory/partials/result_row.html new file mode 100644 index 00000000..9e38ad3b --- /dev/null +++ b/laboratory/templates/laboratory/partials/result_row.html @@ -0,0 +1,77 @@ + + +
+ +
+ + +
+
+
{{ result.order.patient.get_full_name }}
+
{{ result.order.patient.patient_id }}
+
{{ result.order.patient.date_of_birth|date:"M d, Y" }}
+
+
+ + +
{{ result.test.name }}
+
{{ result.test.code }}
+
Order: {{ result.order.order_number }}
+ + +
+ {{ result.value }} +
+
{{ result.unit }}
+ + +
{{ result.reference_range|default:"--" }}
+ + + {% if result.critical_flag == 'CRITICAL' %} + Critical + {% elif result.critical_flag == 'ABNORMAL' %} + Abnormal + {% elif result.critical_flag == 'NORMAL' %} + Normal + {% else %} + -- + {% endif %} + + + + {{ result.get_status_display }} + + {% if result.status == 'PENDING' and result.critical_flag == 'CRITICAL' %} +
+ Needs Review +
+ {% endif %} + + +
{{ result.result_date|date:"M d, Y" }}
+
{{ result.result_date|time:"H:i" }}
+ + +
{{ result.technologist.get_full_name }}
+ {% if result.verified_by %} +
Verified by: {{ result.verified_by.get_full_name }}
+ {% endif %} + + +
+ + + + {% if result.status == 'PENDING' %} + + {% endif %} +
+ + diff --git a/laboratory/templates/laboratory/partials/result_stats.html b/laboratory/templates/laboratory/partials/result_stats.html new file mode 100644 index 00000000..e6339f5c --- /dev/null +++ b/laboratory/templates/laboratory/partials/result_stats.html @@ -0,0 +1,37 @@ +{% load static %} + + +
+
+
+
+
{{ stats.total_results }}
+
Total Results
+
+
+
+
+
+
+
{{ stats.pending_review }}
+
Pending Review
+
+
+
+
+
+
+
{{ stats.critical_results }}
+
Critical Results
+
+
+
+
+
+
+
{{ stats.verified_results }}
+
Verified
+
+
+
+
diff --git a/laboratory/templates/laboratory/quality_control/qc_sample_form.html b/laboratory/templates/laboratory/quality_control/qc_sample_form.html index 3344910c..34aff836 100644 --- a/laboratory/templates/laboratory/quality_control/qc_sample_form.html +++ b/laboratory/templates/laboratory/quality_control/qc_sample_form.html @@ -5,7 +5,7 @@ {% if qc_sample.pk %}Edit QC Sample - {{ qc_sample.sample_id }}{% else %}Create QC Sample{% endif %} {% endblock %} -{% block extra_css %} +{% block css %} {% endblock %} - diff --git a/pharmacy/templates/pharmacy/prescription_list.html b/pharmacy/templates/pharmacy/prescription_list.html index 4676f44b..ddda1053 100644 --- a/pharmacy/templates/pharmacy/prescription_list.html +++ b/pharmacy/templates/pharmacy/prescription_list.html @@ -4,55 +4,78 @@ {% block title %}Prescriptions - {{ block.super }}{% endblock %} {% block content %} -
-
-
-
-
-

- Prescription Management -

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

+ Prescription Management + Manage and track medication prescriptions +

+ + + +
+ +
+ +
+ +
+

Prescription Records

+
+ + + + +
+
+ + +
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
@@ -195,11 +218,15 @@ {% endif %} - {% endif %} -
-
-
-
+ {% endif %} +
+ +
+ +
+ +
+
@@ -262,4 +289,3 @@ {% endif %} {% endfor %} {% endblock %} - diff --git a/pharmacy/templates/pharmacy/prescriptions/prescription_detail.html b/pharmacy/templates/pharmacy/prescriptions/prescription_detail.html index 0170b165..adc10394 100644 --- a/pharmacy/templates/pharmacy/prescriptions/prescription_detail.html +++ b/pharmacy/templates/pharmacy/prescriptions/prescription_detail.html @@ -38,7 +38,7 @@ Dispense {% endif %} - + Edit
diff --git a/pharmacy/urls.py b/pharmacy/urls.py index 8d3a78fc..855c89a6 100644 --- a/pharmacy/urls.py +++ b/pharmacy/urls.py @@ -23,6 +23,12 @@ urlpatterns = [ path('drug-interactions/', views.DrugInteractionListView.as_view(), name='drug_interaction_list'), path('drug-interactions//', views.DrugInteractionDetailView.as_view(), name='drug_interaction_detail'), + # Medication Administration URLs + path('administration/', views.MedicationAdministrationListView.as_view(), name='administration_list'), + path('administration/create/', views.MedicationAdministrationCreateView.as_view(), name='administration_create'), + path('administration//', views.MedicationAdministrationDetailView.as_view(), name='administration_detail'), + path('administration//update/', views.MedicationAdministrationUpdateView.as_view(), name='administration_update'), + path('medications/create/', views.MedicationCreateView.as_view(), name='medication_create'), path('medications//', views.MedicationDetailView.as_view(), name='medication_detail'), path('medications//update/', views.MedicationUpdateView.as_view(), name='medication_update'), @@ -46,4 +52,3 @@ urlpatterns = [ # API endpoints # path('api/', include('pharmacy.api.urls')), ] - diff --git a/pharmacy/views.py b/pharmacy/views.py index 88feb2d8..b0923b23 100644 --- a/pharmacy/views.py +++ b/pharmacy/views.py @@ -18,8 +18,14 @@ import json from core.models import AuditLogEntry from core.utils import AuditLogger -from .models import * -from .forms import * +from .models import ( + Medication, Prescription, MedicationInventoryItem, DispenseRecord, + MedicationAdministration, DrugInteraction +) +from .forms import ( + MedicationForm, PrescriptionForm, MedicationInventoryItemForm, DispenseRecordForm, + MedicationAdministrationForm, DrugInteractionForm, PharmacySearchForm +) class PharmacyDashboardView(LoginRequiredMixin, ListView): @@ -55,10 +61,10 @@ class PharmacyDashboardView(LoginRequiredMixin, ListView): # tenant=tenant, # quantity_available__lte=F('minimum_stock_level') # ).count(), - 'expired_medications': InventoryItem.objects.filter( - tenant=tenant, - expiration_date__lte=today - ).count(), + # 'expired_medications': InventoryItem.objects.filter( + # tenant=tenant, + # expiration_date__lte=today + # ).count(), 'todays_dispensed': DispenseRecord.objects.filter( prescription__tenant=tenant, date_dispensed__date=today @@ -114,7 +120,7 @@ class PrescriptionListView(LoginRequiredMixin, ListView): # Filter by provider provider_id = self.request.GET.get('provider') if provider_id: - queryset = queryset.filter(prescribing_provider_id=provider_id) + queryset = queryset.filter(prescriber__provider_id=provider_id) # Filter by date range date_from = self.request.GET.get('date_from') @@ -125,14 +131,14 @@ class PrescriptionListView(LoginRequiredMixin, ListView): queryset = queryset.filter(prescribed_datetime__date__lte=date_to) return queryset.select_related( - 'patient', 'medication', 'prescribing_provider' - ).order_by('-prescribed_datetime') + 'patient', 'medication', 'prescriber' + ).order_by('-date_prescribed') def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context.update({ 'prescription_statuses': Prescription._meta.get_field('status').choices, - 'priorities': Prescription._meta.get_field('priority').choices, + # 'priorities': Prescription._meta.get_field('priority').choices, }) return context @@ -186,13 +192,14 @@ class PrescriptionDetailView(LoginRequiredMixin, DetailView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) prescription = self.object + tenant = self.request.user.tenant # Get related data context.update({ - 'dispense_records': prescription.dispense_records.all().order_by('-dispensed_datetime'), + 'dispense_records': prescription.dispense_records.all().order_by('-date_dispensed'), 'drug_interactions': DrugInteraction.objects.filter( - Q(medication_a=prescription.medication) | Q(medication_b=prescription.medication), - tenant=self.request.user.tenant + Q(medication_1=prescription.medication) | Q(medication_2=prescription.medication), + tenant=tenant ), }) @@ -398,24 +405,23 @@ class MedicationListView(LoginRequiredMixin, ListView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context.update({ - 'medication_types': Medication.DOSAGE_FORM_CHOICES, - 'controlled_schedules': Medication.CONTROLLED_SUBSTANCE_SCHEDULE_CHOICES, + 'medication_types': Medication.DosageForm.choices, + 'controlled_schedules': Medication.ControlledSubstanceSchedule.choices, }) return context -# # Inventory Views class InventoryItemListView(LoginRequiredMixin, ListView): """ List view for inventory items with search and filtering. """ - model = InventoryItem + model = MedicationInventoryItem template_name = 'pharmacy/inventory/inventory_list.html' context_object_name = 'inventory_items' paginate_by = 20 def get_queryset(self): - queryset = InventoryItem.objects.filter( + queryset = MedicationInventoryItem.objects.filter( medication__tenant=self.request.user.tenant ).select_related('medication').order_by('medication__generic_name', 'expiration_date') @@ -446,7 +452,7 @@ class InventoryItemListView(LoginRequiredMixin, ListView): expiring_soon = form.cleaned_data.get('expiring_soon') if expiring_soon: threshold = timezone.now().date() + timedelta(days=30) - queryset = queryset.filter(expiry_date__lte=threshold) + queryset = queryset.filter(expiration_date__lte=threshold) is_active = form.cleaned_data.get('is_active') if is_active == 'true': @@ -469,12 +475,12 @@ class InventoryItemDetailView(LoginRequiredMixin, DetailView): """ Detail view for inventory item with dispense history. """ - model = InventoryItem + model = MedicationInventoryItem template_name = 'pharmacy/inventory/inventory_detail.html' context_object_name = 'inventory_item' def get_queryset(self): - return InventoryItem.objects.filter( + return MedicationInventoryItem.objects.filter( medication__tenant=self.request.user.tenant ).select_related('medication') @@ -510,8 +516,8 @@ class InventoryItemCreateView(LoginRequiredMixin, CreateView): """ Create view for inventory item. """ - model = InventoryItem - form_class = InventoryItemForm + model = MedicationInventoryItem + form_class = MedicationInventoryItemForm template_name = 'pharmacy/inventory/inventory_form.html' success_url = reverse_lazy('pharmacy:inventory_list') @@ -546,8 +552,8 @@ class InventoryItemUpdateView(LoginRequiredMixin, UpdateView): """ Update view for inventory item. """ - model = InventoryItem - form_class = InventoryItemForm + model = MedicationInventoryItem + form_class = MedicationInventoryItemForm template_name = 'pharmacy/inventory/inventory_form.html' def get_queryset(self): @@ -626,10 +632,10 @@ class DrugInteractionListView(LoginRequiredMixin, ListView): template_name = 'pharmacy/interactions/drug_interaction_list.html' context_object_name = 'interactions' paginate_by = 25 - + def get_queryset(self): queryset = DrugInteraction.objects.filter(tenant=self.request.user.tenant) - + # Search functionality search = self.request.GET.get('search') if search: @@ -638,19 +644,19 @@ class DrugInteractionListView(LoginRequiredMixin, ListView): Q(medication_b__name__icontains=search) | Q(interaction_description__icontains=search) ) - + # Filter by severity severity = self.request.GET.get('severity') if severity: queryset = queryset.filter(severity=severity) - + # Filter by interaction type interaction_type = self.request.GET.get('interaction_type') if interaction_type: queryset = queryset.filter(interaction_type=interaction_type) - + return queryset.select_related('medication_1', 'medication_2').order_by('-severity', 'medication_1') - + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context.update({ @@ -660,7 +666,173 @@ class DrugInteractionListView(LoginRequiredMixin, ListView): return context -# HTMX Views for real-time updates +# Medication Administration Views +class MedicationAdministrationListView(LoginRequiredMixin, ListView): + """ + List view for medication administration records. + """ + model = MedicationAdministration + template_name = 'pharmacy/administration_list.html' + context_object_name = 'administrations' + paginate_by = 25 + + def get_queryset(self): + queryset = MedicationAdministration.objects.filter( + encounter__tenant=self.request.user.tenant + ).select_related( + 'prescription__patient', 'prescription__medication', 'administered_by' + ).order_by('-scheduled_datetime') + + # Search functionality + search = self.request.GET.get('search') + if search: + queryset = queryset.filter( + Q(prescription__patient__first_name__icontains=search) | + Q(prescription__patient__last_name__icontains=search) | + Q(prescription__patient__mrn__icontains=search) | + Q(prescription__medication__generic_name__icontains=search) | + Q(prescription__medication__brand_name__icontains=search) + ) + + # Filter by status + status = self.request.GET.get('status') + if status: + queryset = queryset.filter(status=status) + + # Filter by route + route = self.request.GET.get('route') + if route: + queryset = queryset.filter(route_given=route) + + # Filter by date range + date_from = self.request.GET.get('date_from') + date_to = self.request.GET.get('date_to') + if date_from: + queryset = queryset.filter(scheduled_datetime__date__gte=date_from) + if date_to: + queryset = queryset.filter(scheduled_datetime__date__lte=date_to) + + return queryset + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + tenant = self.request.user.tenant + today = timezone.now().date() + + # Statistics + context['todays_administrations'] = MedicationAdministration.objects.filter( + encounter__tenant=tenant, + scheduled_datetime__date=today + ).count() + + context['given_today'] = MedicationAdministration.objects.filter( + encounter__tenant=tenant, + scheduled_datetime__date=today, + status='GIVEN' + ).count() + + context['pending_administrations'] = MedicationAdministration.objects.filter( + encounter__tenant=tenant, + scheduled_datetime__gte=timezone.now(), + status='SCHEDULED' + ).count() + + context['overdue_administrations'] = MedicationAdministration.objects.filter( + encounter__tenant=tenant, + scheduled_datetime__lt=timezone.now(), + status='SCHEDULED' + ).count() + + return context + + +class MedicationAdministrationDetailView(LoginRequiredMixin, DetailView): + """ + Detail view for medication administration record. + """ + model = MedicationAdministration + template_name = 'pharmacy/administration_detail.html' + context_object_name = 'administration' + + def get_queryset(self): + return MedicationAdministration.objects.filter( + tenant=self.request.user.tenant + ).select_related( + 'prescription__patient', 'prescription__medication', + 'administered_by', 'witnessed_by', 'double_checked_by' + ) + + +class MedicationAdministrationCreateView(LoginRequiredMixin, CreateView): + """ + Create view for medication administration record. + """ + model = MedicationAdministration + form_class = MedicationAdministrationForm + template_name = 'pharmacy/administration_form.html' + success_url = reverse_lazy('pharmacy:administration_list') + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['user'] = self.request.user + return kwargs + + def form_valid(self, form): + form.instance.tenant = self.request.user.tenant + response = super().form_valid(form) + + # Create audit log + AuditLogEntry.objects.create( + tenant=self.request.user.tenant, + user=self.request.user, + action='CREATE', + model_name='MedicationAdministration', + object_id=self.object.id, + changes={ + 'prescription': str(self.object.prescription), + 'status': self.object.status + } + ) + + messages.success(self.request, 'Medication administration recorded successfully.') + return response + + +class MedicationAdministrationUpdateView(LoginRequiredMixin, UpdateView): + """ + Update view for medication administration record. + """ + model = MedicationAdministration + form_class = MedicationAdministrationForm + template_name = 'pharmacy/administration_form.html' + + def get_queryset(self): + return MedicationAdministration.objects.filter(tenant=self.request.user.tenant) + + def get_success_url(self): + return reverse('pharmacy:administration_detail', kwargs={'pk': self.object.pk}) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['user'] = self.request.user + return kwargs + + def form_valid(self, form): + response = super().form_valid(form) + + # Create audit log + AuditLogEntry.objects.create( + tenant=self.request.user.tenant, + user=self.request.user, + action='UPDATE', + model_name='MedicationAdministration', + object_id=self.object.id, + changes={'status': self.object.status} + ) + + messages.success(self.request, 'Medication administration updated successfully.') + return response + @login_required def pharmacy_stats(request): @@ -681,11 +853,11 @@ def pharmacy_stats(request): ).count(), 'low_stock_items': InventoryItem.objects.filter( tenant=tenant, - current_stock__lte=F('minimum_stock_level') + quantity_on_hand__lte=F('reorder_point') ).count(), 'expired_medications': InventoryItem.objects.filter( tenant=tenant, - expiry_date__lte=today + expiration_date__lte=today ).count(), } @@ -736,20 +908,20 @@ def inventory_alerts(request): # Low stock items low_stock = InventoryItem.objects.filter( tenant=tenant, - current_stock__lte=F('minimum_stock_level') + quantity_on_hand__lte=F('reorder_point') ).select_related('medication')[:10] # Expiring items expiring = InventoryItem.objects.filter( tenant=tenant, - expiry_date__lte=thirty_days, - expiry_date__gt=today + expiration_date__lte=thirty_days, + expiration_date__gt=today ).select_related('medication')[:10] - + # Expired items expired = InventoryItem.objects.filter( tenant=tenant, - expiry_date__lte=today + expiration_date__lte=today ).select_related('medication')[:10] return render(request, 'pharmacy/partials/inventory_alerts.html', { @@ -782,8 +954,6 @@ def drug_interaction_check(request, prescription_id): }) -# Action Views - @login_required def verify_prescription(request, prescription_id): """ @@ -1864,6 +2034,7 @@ def get_medication_info(request, pk): # return queryset # # + class DrugInteractionCreateView(LoginRequiredMixin, CreateView): """ Create view for drug interaction. @@ -1899,180 +2070,176 @@ class DrugInteractionCreateView(LoginRequiredMixin, CreateView): messages.success(self.request, 'Drug interaction created successfully.') return response + class DrugInteractionDetailView(LoginRequiredMixin, DetailView): model = DrugInteraction template_name = 'pharmacy/interactions/drug_interaction_detail.html' context_object_name = 'interaction' +# HTMX Views +@login_required +def htmx_pharmacy_stats(request): + """ + HTMX view for real-time pharmacy statistics. + """ + user = request.user + today = timezone.now().date() + + stats = { + 'total_medications': Medication.objects.filter( + tenant=user.tenant, is_active=True + ).count(), + 'active_prescriptions': Prescription.objects.filter( + patient__tenant=user.tenant, status='ACTIVE' + ).count(), + 'pending_prescriptions': Prescription.objects.filter( + patient__tenant=user.tenant, status='PENDING' + ).count(), + 'low_stock_items': InventoryItem.objects.filter( + medication__tenant=user.tenant, + is_active=True, + quantity_on_hand__lte=F('reorder_point') + ).count(), + 'prescriptions_today': Prescription.objects.filter( + patient__tenant=user.tenant, + prescribed_date__date=today + ).count(), + 'dispensed_today': DispenseRecord.objects.filter( + prescription__patient__tenant=user.tenant, + dispensed_date__date=today + ).count(), + } + + return render(request, 'pharmacy/partials/pharmacy_stats.html', {'stats': stats}) +@login_required +def htmx_prescription_search(request): + """ + HTMX view for prescription search. + """ + search = request.GET.get('search', '') + prescriptions = Prescription.objects.filter( + patient__tenant=request.user.tenant + ).select_related('patient', 'medication', 'prescriber') + + if search: + prescriptions = prescriptions.filter( + Q(patient__first_name__icontains=search) | + Q(patient__last_name__icontains=search) | + Q(medication__name__icontains=search) + ) + + prescriptions = prescriptions.order_by('-prescribed_date')[:10] + + return render(request, 'pharmacy/partials/prescription_list.html', { + 'prescriptions': prescriptions + }) + + +# Action Views +@login_required +def fill_prescription(request, pk): + """ + Action view to fill a prescription. + """ + prescription = get_object_or_404( + Prescription, + pk=pk, + patient__tenant=request.user.tenant + ) + + if prescription.status != 'ACTIVE': + messages.error(request, 'Only active prescriptions can be filled.') + return redirect('pharmacy:prescription_detail', pk=pk) + + # Check inventory availability + available_inventory = InventoryItem.objects.filter( + medication=prescription.medication, + is_active=True, + quantity_on_hand__gte=prescription.quantity + ).first() + + if not available_inventory: + messages.error(request, 'Insufficient inventory to fill this prescription.') + return redirect('pharmacy:prescription_detail', pk=pk) + + if request.method == 'POST': + # Create dispense record + dispense_record = DispenseRecord.objects.create( + prescription=prescription, + inventory_item=available_inventory, + quantity_dispensed=prescription.quantity, + dispensed_by=request.user, + dispensed_date=timezone.now(), + patient_counseled=True + ) + + # Update inventory + available_inventory.quantity_on_hand -= prescription.quantity + available_inventory.save() + + # Update prescription status + prescription.status = 'FILLED' + prescription.save() + + # Create audit log + AuditLogEntry.objects.create( + tenant=request.user.tenant, + user=request.user, + action='UPDATE', + model_name='Prescription', + object_id=prescription.id, + changes={'status': 'FILLED', 'filled_by': str(request.user)} + ) + + messages.success(request, 'Prescription filled successfully.') + return redirect('pharmacy:prescription_detail', pk=pk) + + return render(request, 'pharmacy/fill_prescription.html', { + 'prescription': prescription, + 'available_inventory': available_inventory + }) + + +@login_required +def cancel_prescription(request, pk): + """ + Action view to cancel a prescription. + """ + prescription = get_object_or_404( + Prescription, + pk=pk, + patient__tenant=request.user.tenant + ) + + if prescription.status in ['FILLED', 'CANCELLED']: + messages.error(request, 'Cannot cancel filled or already cancelled prescriptions.') + return redirect('pharmacy:prescription_detail', pk=pk) + + if request.method == 'POST': + prescription.status = 'CANCELLED' + prescription.save() + + # Create audit log + AuditLogEntry.objects.create( + tenant=request.user.tenant, + user=request.user, + action='UPDATE', + model_name='Prescription', + object_id=prescription.id, + changes={'status': 'CANCELLED', 'cancelled_by': str(request.user)} + ) + + messages.success(request, 'Prescription cancelled successfully.') + return redirect('pharmacy:prescription_detail', pk=pk) + + return render(request, 'pharmacy/cancel_prescription.html', { + 'prescription': prescription + }) + -# -# -# # HTMX Views -# @login_required -# def htmx_pharmacy_stats(request): -# """ -# HTMX view for real-time pharmacy statistics. -# """ -# user = request.user -# today = timezone.now().date() -# -# stats = { -# 'total_medications': Medication.objects.filter( -# tenant=user.tenant, is_active=True -# ).count(), -# 'active_prescriptions': Prescription.objects.filter( -# patient__tenant=user.tenant, status='ACTIVE' -# ).count(), -# 'pending_prescriptions': Prescription.objects.filter( -# patient__tenant=user.tenant, status='PENDING' -# ).count(), -# 'low_stock_items': InventoryItem.objects.filter( -# medication__tenant=user.tenant, -# is_active=True, -# quantity_on_hand__lte=F('reorder_point') -# ).count(), -# 'prescriptions_today': Prescription.objects.filter( -# patient__tenant=user.tenant, -# prescribed_date__date=today -# ).count(), -# 'dispensed_today': DispenseRecord.objects.filter( -# prescription__patient__tenant=user.tenant, -# dispensed_date__date=today -# ).count(), -# } -# -# return render(request, 'pharmacy/partials/pharmacy_stats.html', {'stats': stats}) -# -# -# @login_required -# def htmx_prescription_search(request): -# """ -# HTMX view for prescription search. -# """ -# search = request.GET.get('search', '') -# prescriptions = Prescription.objects.filter( -# patient__tenant=request.user.tenant -# ).select_related('patient', 'medication', 'prescriber') -# -# if search: -# prescriptions = prescriptions.filter( -# Q(patient__first_name__icontains=search) | -# Q(patient__last_name__icontains=search) | -# Q(medication__name__icontains=search) -# ) -# -# prescriptions = prescriptions.order_by('-prescribed_date')[:10] -# -# return render(request, 'pharmacy/partials/prescription_list.html', { -# 'prescriptions': prescriptions -# }) -# -# -# # Action Views -# @login_required -# def fill_prescription(request, pk): -# """ -# Action view to fill a prescription. -# """ -# prescription = get_object_or_404( -# Prescription, -# pk=pk, -# patient__tenant=request.user.tenant -# ) -# -# if prescription.status != 'ACTIVE': -# messages.error(request, 'Only active prescriptions can be filled.') -# return redirect('pharmacy:prescription_detail', pk=pk) -# -# # Check inventory availability -# available_inventory = InventoryItem.objects.filter( -# medication=prescription.medication, -# is_active=True, -# quantity_on_hand__gte=prescription.quantity -# ).first() -# -# if not available_inventory: -# messages.error(request, 'Insufficient inventory to fill this prescription.') -# return redirect('pharmacy:prescription_detail', pk=pk) -# -# if request.method == 'POST': -# # Create dispense record -# dispense_record = DispenseRecord.objects.create( -# prescription=prescription, -# inventory_item=available_inventory, -# quantity_dispensed=prescription.quantity, -# dispensed_by=request.user, -# dispensed_date=timezone.now(), -# patient_counseled=True -# ) -# -# # Update inventory -# available_inventory.quantity_on_hand -= prescription.quantity -# available_inventory.save() -# -# # Update prescription status -# prescription.status = 'FILLED' -# prescription.save() -# -# # Create audit log -# AuditLogEntry.objects.create( -# tenant=request.user.tenant, -# user=request.user, -# action='UPDATE', -# model_name='Prescription', -# object_id=prescription.id, -# changes={'status': 'FILLED', 'filled_by': str(request.user)} -# ) -# -# messages.success(request, 'Prescription filled successfully.') -# return redirect('pharmacy:prescription_detail', pk=pk) -# -# return render(request, 'pharmacy/fill_prescription.html', { -# 'prescription': prescription, -# 'available_inventory': available_inventory -# }) -# -# -# @login_required -# def cancel_prescription(request, pk): -# """ -# Action view to cancel a prescription. -# """ -# prescription = get_object_or_404( -# Prescription, -# pk=pk, -# patient__tenant=request.user.tenant -# ) -# -# if prescription.status in ['FILLED', 'CANCELLED']: -# messages.error(request, 'Cannot cancel filled or already cancelled prescriptions.') -# return redirect('pharmacy:prescription_detail', pk=pk) -# -# if request.method == 'POST': -# prescription.status = 'CANCELLED' -# prescription.save() -# -# # Create audit log -# AuditLogEntry.objects.create( -# tenant=request.user.tenant, -# user=request.user, -# action='UPDATE', -# model_name='Prescription', -# object_id=prescription.id, -# changes={'status': 'CANCELLED', 'cancelled_by': str(request.user)} -# ) -# -# messages.success(request, 'Prescription cancelled successfully.') -# return redirect('pharmacy:prescription_detail', pk=pk) -# -# return render(request, 'pharmacy/cancel_prescription.html', { -# 'prescription': prescription -# }) -# -# @login_required def adjust_inventory(request, pk): """ diff --git a/populate_all_data.py b/populate_all_data.py new file mode 100644 index 00000000..f8112714 --- /dev/null +++ b/populate_all_data.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +""" +Master orchestrator for Saudi healthcare data generation. +Runs all data generators in the correct dependency order. +""" + +import os +import sys +import argparse +from datetime import datetime + +# Add current directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from data_utils.base import DataGenerationOrchestrator +from data_utils.helpers import validate_dependencies + + +def create_orchestrator(): + """Create and configure the data generation orchestrator""" + orchestrator = DataGenerationOrchestrator() + + # Import and register all generators + try: + from core_data import SaudiCoreDataGenerator + orchestrator.register_generator('core', SaudiCoreDataGenerator) + except ImportError: + print("Warning: core_data.py not found or not refactored yet") + + try: + from accounts_data import SaudiAccountsDataGenerator + orchestrator.register_generator('accounts', SaudiAccountsDataGenerator) + except ImportError: + print("Warning: accounts_data.py not found or not refactored yet") + + try: + from hr_data import SaudiHRDataGenerator + orchestrator.register_generator('hr', SaudiHRDataGenerator) + except ImportError: + print("Warning: hr_data.py not found or not refactored yet") + + try: + from patients_data import SaudiPatientsDataGenerator + orchestrator.register_generator('patients', SaudiPatientsDataGenerator) + except ImportError: + print("Warning: patients_data.py not found or not refactored yet") + + try: + from emr_data import SaudiEMRDataGenerator + orchestrator.register_generator('emr', SaudiEMRDataGenerator) + except ImportError: + print("Warning: emr_data.py not found or not refactored yet") + + try: + from lab_data import SaudiLabDataGenerator + orchestrator.register_generator('lab', SaudiLabDataGenerator) + except ImportError: + print("Warning: lab_data.py not found or not refactored yet") + + try: + from radiology_data import SaudiRadiologyDataGenerator + orchestrator.register_generator('radiology', SaudiRadiologyDataGenerator) + except ImportError: + print("Warning: radiology_data.py not found or not refactored yet") + + try: + from pharmacy_data import SaudiPharmacyDataGenerator + orchestrator.register_generator('pharmacy', SaudiPharmacyDataGenerator) + except ImportError: + print("Warning: pharmacy_data.py not found or not refactored yet") + + try: + from appointments_data import SaudiAppointmentsDataGenerator + orchestrator.register_generator('appointments', SaudiAppointmentsDataGenerator) + except ImportError: + print("Warning: appointments_data.py not found or not refactored yet") + + try: + from billing_data import SaudiBillingDataGenerator + orchestrator.register_generator('billing', SaudiBillingDataGenerator) + except ImportError: + print("Warning: billing_data.py not found or not refactored yet") + + try: + from inpatients_data import SaudiInpatientsDataGenerator + orchestrator.register_generator('inpatients', SaudiInpatientsDataGenerator) + except ImportError: + print("Warning: inpatients_data.py not found or not refactored yet") + + try: + from inventory_data import SaudiInventoryDataGenerator + orchestrator.register_generator('inventory', SaudiInventoryDataGenerator) + except ImportError: + print("Warning: inventory_data.py not found or not refactored yet") + + # Facility management uses management command + try: + from facility_management.management.commands.seed_facility import Command as FacilityCommand + orchestrator.register_generator('facility_management', FacilityCommand) + except ImportError: + print("Warning: facility_management command not available") + + return orchestrator + + +def main(): + """Main function to run data generation""" + parser = argparse.ArgumentParser(description='Generate Saudi healthcare test data') + parser.add_argument('--generators', nargs='*', + help='Specific generators to run (default: all in dependency order)') + parser.add_argument('--list-generators', action='store_true', + help='List available generators and exit') + parser.add_argument('--show-plan', action='store_true', + help='Show execution plan and exit') + parser.add_argument('--validate-only', action='store_true', + help='Validate dependencies and exit') + parser.add_argument('--tenant-id', type=int, + help='Tenant ID to generate data for (default: all active tenants)') + parser.add_argument('--tenant-slug', type=str, + help='Tenant slug to generate data for') + parser.add_argument('--skip-validation', action='store_true', + help='Skip dependency validation') + + args = parser.parse_args() + + print("🏥 Saudi Healthcare Data Generation Orchestrator") + print("=" * 55) + + # Create orchestrator + orchestrator = create_orchestrator() + + # List generators if requested + if args.list_generators: + print("\n📋 Available Generators:") + for gen in orchestrator.get_available_generators(): + print(f" - {gen}") + return + + # Show execution plan if requested + if args.show_plan: + generators_to_run = args.generators or orchestrator.execution_order + plan = orchestrator.get_execution_plan(generators_to_run) + + print("\n📋 Execution Plan:") + for item in plan: + deps = ', '.join(item['dependencies']) if item['dependencies'] else 'None' + print(f" {item['name']} ({item['class']})") + print(f" Dependencies: {deps}") + return + + # Validate dependencies + if not args.skip_validation: + try: + deps = validate_dependencies() + print(f"✅ Dependencies validated: {deps['tenants']} tenants, {deps['users']} users") + except ValueError as e: + print(f"❌ Dependency validation failed: {e}") + return + + # Validate only if requested + if args.validate_only: + print("✅ Validation complete - all dependencies satisfied") + return + + # Determine generators to run + generators_to_run = args.generators or orchestrator.execution_order + + # Prepare kwargs for generators + generator_kwargs = {} + if args.tenant_id: + generator_kwargs['tenant_id'] = args.tenant_id + if args.tenant_slug: + generator_kwargs['tenant_slug'] = args.tenant_slug + + # Run generators + start_time = datetime.now() + print(f"\n🚀 Starting data generation at {start_time}") + print(f"📦 Generators to run: {', '.join(generators_to_run)}") + + results = orchestrator.run_all(generators_to_run, **generator_kwargs) + + # Print summary + end_time = datetime.now() + duration = end_time - start_time + + print(f"\n🎉 Data generation completed at {end_time}") + print(f"⏱️ Total duration: {duration}") + + if results: + print(f"\n📊 Generation Summary:") + total_created = 0 + for generator_name, result in results.items(): + if isinstance(result, dict): + count = sum(result.values()) if all(isinstance(v, int) for v in result.values()) else len(result) + print(f" {generator_name}: {count} records") + total_created += count + else: + print(f" {generator_name}: {result}") + + print(f"\n💡 Total records created: {total_created}") + else: + print("\n⚠️ No data was generated") + + print("\n✅ Saudi healthcare data generation orchestrator completed successfully!") + + +if __name__ == "__main__": + main() diff --git a/populate_data.sh b/populate_data.sh new file mode 100755 index 00000000..f95a13f6 --- /dev/null +++ b/populate_data.sh @@ -0,0 +1,271 @@ +#!/bin/bash + +# Saudi Healthcare Data Population Script +# This script runs all data generators in the correct dependency order + +set -e # Exit on any error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Configuration +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PYTHON_CMD="python3" +ORCHESTRATOR="${SCRIPT_DIR}/populate_all_data.py" + +# Function to print colored output +print_info() { + echo -e "${BLUE}ℹ️ $1${NC}" +} + +print_success() { + echo -e "${GREEN}✅ $1${NC}" +} + +print_warning() { + echo -e "${YELLOW}⚠️ $1${NC}" +} + +print_error() { + echo -e "${RED}❌ $1${NC}" +} + +print_header() { + echo -e "${BLUE}🏥 $1${NC}" + echo -e "${BLUE}$(printf '%.0s=' {1..50})${NC}" +} + +# Function to check if Python is available +check_python() { + if ! command -v $PYTHON_CMD &> /dev/null; then + print_error "Python3 is not installed or not in PATH" + exit 1 + fi + + # Check if Django is available + if ! $PYTHON_CMD -c "import django" &> /dev/null; then + print_error "Django is not installed. Please install requirements." + exit 1 + fi +} + +# Function to check if we're in the right directory +check_directory() { + if [[ ! -f "manage.py" ]]; then + print_error "manage.py not found. Please run this script from the Django project root." + exit 1 + fi + + if [[ ! -f "$ORCHESTRATOR" ]]; then + print_error "populate_all_data.py not found. Please ensure the script exists." + exit 1 + fi +} + +# Function to show usage +show_usage() { + cat << EOF +Saudi Healthcare Data Population Script + +USAGE: + $0 [OPTIONS] [GENERATORS...] + +OPTIONS: + -h, --help Show this help message + -l, --list List available generators + -p, --plan Show execution plan + -v, --validate Validate dependencies only + --tenant-id ID Generate data for specific tenant ID + --tenant-slug SLUG Generate data for specific tenant slug + --skip-validation Skip dependency validation + --dry-run Show what would be done without executing + +GENERATORS: + Specify which generators to run. If none specified, runs all in dependency order: + core accounts hr patients emr lab radiology pharmacy appointments billing inpatients inventory facility_management + +EXAMPLES: + $0 # Run all generators + $0 core accounts patients # Run specific generators + $0 --tenant-id 1 # Generate data for tenant ID 1 + $0 --list # List available generators + $0 --plan # Show execution plan + $0 --validate # Validate dependencies only + +DEPENDENCY ORDER: + 1. core (tenants) + 2. accounts (users) + 3. hr (employees/departments) + 4. patients (patients) + 5. emr, lab, radiology, pharmacy (clinical data - parallel) + 6. appointments (needs patients + providers) + 7. billing (needs patients + encounters) + 8. inpatients (needs patients + staff) + 9. inventory (independent) + 10. facility_management (management command) + +EOF +} + +# Function to run the orchestrator +run_orchestrator() { + local args=("$@") + + print_info "Running data generation orchestrator..." + + if [[ "${args[*]}" =~ "--dry-run" ]]; then + print_warning "DRY RUN MODE - No data will be created" + args=("${args[@]/--dry-run/}") + fi + + # Run the Python orchestrator + if $PYTHON_CMD "$ORCHESTRATOR" "${args[@]}"; then + print_success "Data generation completed successfully!" + else + print_error "Data generation failed!" + exit 1 + fi +} + +# Function to run individual generators (legacy support) +run_individual() { + local generator="$1" + + print_info "Running individual generator: $generator" + + case $generator in + "core") + $PYTHON_CMD core_data.py + ;; + "accounts") + $PYTHON_CMD accounts_data.py + ;; + "hr") + $PYTHON_CMD hr_data.py + ;; + "patients") + $PYTHON_CMD patients_data.py + ;; + "emr") + $PYTHON_CMD emr_data.py + ;; + "lab") + $PYTHON_CMD lab_data.py + ;; + "radiology") + $PYTHON_CMD radiology_data.py + ;; + "pharmacy") + $PYTHON_CMD pharmacy_data.py + ;; + "appointments") + $PYTHON_CMD appointments_data.py + ;; + "billing") + $PYTHON_CMD billing_data.py + ;; + "inpatients") + $PYTHON_CMD inpatients_data.py + ;; + "inventory") + $PYTHON_CMD inventory_data.py + ;; + "facility_management") + $PYTHON_CMD manage.py seed_facility + ;; + *) + print_error "Unknown generator: $generator" + exit 1 + ;; + esac +} + +# Parse command line arguments +ARGS=() +INDIVIDUAL_MODE=false + +while [[ $# -gt 0 ]]; do + case $1 in + -h|--help) + show_usage + exit 0 + ;; + -l|--list) + ARGS+=("--list-generators") + shift + ;; + -p|--plan) + ARGS+=("--show-plan") + shift + ;; + -v|--validate) + ARGS+=("--validate-only") + shift + ;; + --dry-run) + ARGS+=("--dry-run") + shift + ;; + --tenant-id) + ARGS+=("--tenant-id" "$2") + shift 2 + ;; + --tenant-slug) + ARGS+=("--tenant-slug" "$2") + shift 2 + ;; + --skip-validation) + ARGS+=("--skip-validation") + shift + ;; + --individual) + INDIVIDUAL_MODE=true + shift + ;; + -*) + print_error "Unknown option: $1" + show_usage + exit 1 + ;; + *) + ARGS+=("$1") + shift + ;; + esac +done + +# Main execution +main() { + print_header "Saudi Healthcare Data Population Script" + + # Pre-flight checks + check_python + check_directory + + print_info "Python version: $($PYTHON_CMD --version)" + print_info "Django project root: $SCRIPT_DIR" + + # Handle individual mode (legacy support) + if [[ "$INDIVIDUAL_MODE" == true ]]; then + print_warning "Individual mode is deprecated. Use the orchestrator instead." + if [[ ${#ARGS[@]} -eq 0 ]]; then + print_error "No generator specified for individual mode" + exit 1 + fi + + for generator in "${ARGS[@]}"; do + run_individual "$generator" + done + exit 0 + fi + + # Run orchestrator + run_orchestrator "${ARGS[@]}" +} + +# Run main function +main "$@" diff --git a/pyproject.toml b/pyproject.toml index 11fa41e8..1b5130cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ "django-allauth==65.11.2", "django-anymail==13.1", "django-celery-beat>=2.8.1", - "django-cors-headers==4.8.0", + "django-cors-headers==4.9.0", "django-crispy-forms>=2.4", "django-debug-toolbar>=6.0.0", "django-extensions>=4.1", @@ -28,7 +28,8 @@ dependencies = [ "django-otp>=1.6.1", "django-redis>=6.0.0", "django-storages>=1.14.6", - "django-viewflow>=2.2.12", + "django-viewflow==2.2.13", + "django-webpack-loader>=3.2.1", "djangorestframework==3.16.1", "drf-spectacular>=0.28.0", "factory-boy>=3.3.3", @@ -50,6 +51,7 @@ dependencies = [ "psutil==7.1.0", "psycopg2-binary>=2.9.10", "pydantic-core==2.39.0", + "pydicom>=3.0.1", "pytest==8.4.2", "pytest-django>=4.11.1", "python-dateutil>=2.9.0.post0", diff --git a/quality/__pycache__/models.cpython-312.pyc b/quality/__pycache__/models.cpython-312.pyc index e66e0038..a33fde41 100644 Binary files a/quality/__pycache__/models.cpython-312.pyc and b/quality/__pycache__/models.cpython-312.pyc differ diff --git a/quality/migrations/0001_initial.py b/quality/migrations/0001_initial.py index 784a18ae..e1809abc 100644 --- a/quality/migrations/0001_initial.py +++ b/quality/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-09-19 10:58 +# Generated by Django 5.2.6 on 2025-09-26 18:33 import django.core.validators import django.db.models.deletion @@ -36,16 +36,16 @@ class Migration(migrations.Migration): "audit_type", models.CharField( choices=[ - ("internal", "Internal"), - ("external", "External"), - ("regulatory", "Regulatory"), - ("accreditation", "Accreditation"), - ("quality", "Quality"), - ("compliance", "Compliance"), - ("safety", "Safety"), - ("operational", "Operational"), - ("financial", "Financial"), - ("clinical", "Clinical"), + ("INTERNAL", "Internal"), + ("EXTERNAL", "External"), + ("REGULATORY", "Regulatory"), + ("ACCREDITATION", "Accreditation"), + ("QUALITY", "Quality"), + ("COMPLIANCE", "Compliance"), + ("SAFETY", "Safety"), + ("OPERATIONAL", "Operational"), + ("FINANCIAL", "Financial"), + ("CLINICAL", "Clinical"), ], max_length=20, ), @@ -60,13 +60,13 @@ class Migration(migrations.Migration): "status", models.CharField( choices=[ - ("planned", "Planned"), - ("in_progress", "In Progress"), - ("completed", "Completed"), - ("cancelled", "Cancelled"), - ("postponed", "Postponed"), + ("PLANNED", "Planned"), + ("IN_PROGRESS", "In Progress"), + ("COMPLETED", "Completed"), + ("CANCELLED", "Cancelled"), + ("POSTPONED", "Postponed"), ], - default="planned", + default="PLANNED", max_length=20, ), ), @@ -74,12 +74,12 @@ class Migration(migrations.Migration): "priority", models.CharField( choices=[ - ("low", "Low"), - ("medium", "Medium"), - ("high", "High"), - ("urgent", "Urgent"), + ("LOW", "Low"), + ("MEDIUM", "Medium"), + ("HIGH", "High"), + ("URGENT", "Urgent"), ], - default="medium", + default="MEDIUM", max_length=10, ), ), @@ -158,14 +158,14 @@ class Migration(migrations.Migration): "finding_type", models.CharField( choices=[ - ("non_conformity", "Non-Conformity"), - ("observation", "Observation"), + ("NON_CONFORMITY", "Non-Conformity"), + ("OBSERVATION", "Observation"), ( - "opportunity_for_improvement", + "OPPORTUNITY_FOR_IMPROVEMENT", "Opportunity for Improvement", ), - ("positive_finding", "Positive Finding"), - ("recommendation", "Recommendation"), + ("POSITIVE_FINDING", "Positive Finding"), + ("RECOMMENDATION", "Recommendation"), ], max_length=30, ), @@ -175,9 +175,9 @@ class Migration(migrations.Migration): "severity", models.CharField( choices=[ - ("minor", "Minor"), - ("major", "Major"), - ("critical", "Critical"), + ("MINOR", "Minor"), + ("MAJOR", "Major"), + ("CRITICAL", "Critical"), ], max_length=20, ), @@ -186,16 +186,16 @@ class Migration(migrations.Migration): "category", models.CharField( choices=[ - ("documentation", "Documentation"), - ("process", "Process"), - ("training", "Training"), - ("equipment", "Equipment"), - ("environment", "Environment"), - ("management", "Management"), - ("communication", "Communication"), - ("safety", "Safety"), - ("quality", "Quality"), - ("compliance", "Compliance"), + ("DOCUMENTATION", "Documentation"), + ("PROCESS", "Process"), + ("TRAINING", "Training"), + ("EQUIPMENT", "Equipment"), + ("ENVIRONMENT", "Environment"), + ("MANAGEMENT", "Management"), + ("COMMUNICATION", "Communication"), + ("SAFETY", "Safety"), + ("QUALITY", "Quality"), + ("COMPLIANCE", "Compliance"), ], max_length=20, ), @@ -211,13 +211,13 @@ class Migration(migrations.Migration): "status", models.CharField( choices=[ - ("open", "Open"), - ("in_progress", "In Progress"), - ("completed", "Completed"), - ("verified", "Verified"), - ("closed", "Closed"), + ("OPEN", "Open"), + ("IN_PROGRESS", "In Progress"), + ("COMPLETED", "Completed"), + ("VERIFIED", "Verified"), + ("CLOSED", "Closed"), ], - default="open", + default="OPEN", max_length=20, ), ), @@ -294,16 +294,16 @@ class Migration(migrations.Migration): "project_type", models.CharField( choices=[ - ("quality_improvement", "Quality Improvement"), - ("process_improvement", "Process Improvement"), - ("safety_initiative", "Safety Initiative"), - ("compliance_project", "Compliance Project"), - ("cost_reduction", "Cost Reduction"), - ("efficiency_improvement", "Efficiency Improvement"), - ("patient_satisfaction", "Patient Satisfaction"), - ("staff_satisfaction", "Staff Satisfaction"), - ("technology_implementation", "Technology Implementation"), - ("training_program", "Training Program"), + ("QUALITY_IMPROVEMENT", "Quality Improvement"), + ("PROCESS_IMPROVEMENT", "Process Improvement"), + ("SAFETY_INITIATIVE", "Safety Initiative"), + ("COMPLIANCE_PROJECT", "Compliance Project"), + ("COST_REDUCTION", "Cost Reduction"), + ("EFFICIENCY_IMPROVEMENT", "Efficiency Improvement"), + ("PATIENT_SATISFACTION", "Patient Satisfaction"), + ("STAFF_SATISFACTION", "Staff Satisfaction"), + ("TECHNOLOGY_IMPLEMENTATION", "Technology Implementation"), + ("TRAINING_PROGRAM", "Training Program"), ], max_length=30, ), @@ -312,13 +312,13 @@ class Migration(migrations.Migration): "methodology", models.CharField( choices=[ - ("pdsa", "PDSA (Plan-Do-Study-Act)"), - ("lean", "Lean"), - ("six_sigma", "Six Sigma"), - ("kaizen", "Kaizen"), - ("root_cause_analysis", "Root Cause Analysis"), - ("failure_mode_analysis", "Failure Mode Analysis"), - ("other", "Other"), + ("PDSA", "PDSA (Plan-Do-Study-Act)"), + ("LEAN", "Lean"), + ("SIX_SIGMA", "Six Sigma"), + ("KAIZEN", "Kaizen"), + ("ROOT_CAUSE_ANALYSIS", "Root Cause Analysis"), + ("FAILURE_MODE_ANALYSIS", "Failure Mode Analysis"), + ("OTHER", "Other"), ], max_length=30, ), @@ -337,13 +337,13 @@ class Migration(migrations.Migration): "status", models.CharField( choices=[ - ("planned", "Planned"), - ("active", "Active"), - ("on_hold", "On Hold"), - ("completed", "Completed"), - ("cancelled", "Cancelled"), + ("PLANNED", "Planned"), + ("ACTIVE", "Active"), + ("ON_HOLD", "On Hold"), + ("COMPLETED", "Completed"), + ("CANCELLED", "Cancelled"), ], - default="planned", + default="PLANNED", max_length=20, ), ), @@ -351,17 +351,17 @@ class Migration(migrations.Migration): "phase", models.CharField( choices=[ - ("define", "Define"), - ("measure", "Measure"), - ("analyze", "Analyze"), - ("improve", "Improve"), - ("control", "Control"), - ("plan", "Plan"), - ("do", "Do"), - ("study", "Study"), - ("act", "Act"), + ("DEFINE", "Define"), + ("MEASURE", "Measure"), + ("ANALYZE", "Analyze"), + ("IMPROVE", "Improve"), + ("CONTROL", "Control"), + ("PLAN", "Plan"), + ("DO", "Do"), + ("STUDY", "Study"), + ("ACT", "Act"), ], - default="define", + default="DEFINE", max_length=20, ), ), @@ -813,16 +813,16 @@ class Migration(migrations.Migration): "risk_category", models.CharField( choices=[ - ("clinical", "Clinical"), - ("operational", "Operational"), - ("financial", "Financial"), - ("regulatory", "Regulatory"), - ("reputational", "Reputational"), - ("strategic", "Strategic"), - ("technology", "Technology"), - ("environmental", "Environmental"), - ("security", "Security"), - ("legal", "Legal"), + ("CLINICAL", "Clinical"), + ("OPERATIONAL", "Operational"), + ("FINANCIAL", "Financial"), + ("REGULATORY", "Regulatory"), + ("REPUTATIONAL", "Reputational"), + ("STRATEGIC", "Strategic"), + ("TECHNOLOGY", "Technology"), + ("ENVIRONMENTAL", "Environmental"), + ("SECURITY", "Security"), + ("LEGAL", "Legal"), ], max_length=20, ), @@ -831,16 +831,16 @@ class Migration(migrations.Migration): "risk_type", models.CharField( choices=[ - ("patient_safety", "Patient Safety"), - ("quality", "Quality"), - ("compliance", "Compliance"), - ("financial", "Financial"), - ("operational", "Operational"), - ("strategic", "Strategic"), - ("technology", "Technology"), - ("environmental", "Environmental"), - ("security", "Security"), - ("legal", "Legal"), + ("PATIENT_SAFETY", "Patient Safety"), + ("QUALITY", "Quality"), + ("COMPLIANCE", "Compliance"), + ("FINANCIAL", "Financial"), + ("OPERATIONAL", "Operational"), + ("STRATEGIC", "Strategic"), + ("TECHNOLOGY", "Technology"), + ("ENVIRONMENTAL", "Environmental"), + ("SECURITY", "Security"), + ("LEGAL", "Legal"), ], max_length=20, ), @@ -882,10 +882,10 @@ class Migration(migrations.Migration): "risk_level", models.CharField( choices=[ - ("low", "Low"), - ("medium", "Medium"), - ("high", "High"), - ("critical", "Critical"), + ("LOW", "Low"), + ("MEDIUM", "Medium"), + ("HIGH", "High"), + ("CRITICAL", "Critical"), ], max_length=20, ), @@ -895,10 +895,10 @@ class Migration(migrations.Migration): "control_effectiveness", models.CharField( choices=[ - ("poor", "Poor"), - ("fair", "Fair"), - ("good", "Good"), - ("excellent", "Excellent"), + ("POOR", "Poor"), + ("FAIR", "Fair"), + ("GOOD", "Good"), + ("EXCELLENT", "Excellent"), ], max_length=20, ), @@ -911,13 +911,13 @@ class Migration(migrations.Migration): "status", models.CharField( choices=[ - ("draft", "Draft"), - ("active", "Active"), - ("under_review", "Under Review"), - ("closed", "Closed"), - ("cancelled", "Cancelled"), + ("DRAFT", "Draft"), + ("ACTIVE", "Active"), + ("UNDER_REVIEW", "Under Review"), + ("CLOSED", "Closed"), + ("CANCELLED", "Cancelled"), ], - default="draft", + default="DRAFT", max_length=20, ), ), diff --git a/quality/migrations/__pycache__/0001_initial.cpython-312.pyc b/quality/migrations/__pycache__/0001_initial.cpython-312.pyc index effd660e..3852fba3 100644 Binary files a/quality/migrations/__pycache__/0001_initial.cpython-312.pyc and b/quality/migrations/__pycache__/0001_initial.cpython-312.pyc differ diff --git a/quality/models.py b/quality/models.py index 2da106df..73e2ce62 100644 --- a/quality/models.py +++ b/quality/models.py @@ -242,92 +242,81 @@ class IncidentReport(models.Model): class RiskAssessment(models.Model): """Risk assessments and risk management activities""" - - RISK_CATEGORY_CHOICES = [ - ('clinical', 'Clinical'), - ('operational', 'Operational'), - ('financial', 'Financial'), - ('regulatory', 'Regulatory'), - ('reputational', 'Reputational'), - ('strategic', 'Strategic'), - ('technology', 'Technology'), - ('environmental', 'Environmental'), - ('security', 'Security'), - ('legal', 'Legal'), - ] - - RISK_TYPE_CHOICES = [ - ('patient_safety', 'Patient Safety'), - ('quality', 'Quality'), - ('compliance', 'Compliance'), - ('financial', 'Financial'), - ('operational', 'Operational'), - ('strategic', 'Strategic'), - ('technology', 'Technology'), - ('environmental', 'Environmental'), - ('security', 'Security'), - ('legal', 'Legal'), - ] - - LIKELIHOOD_CHOICES = [ - (1, 'Very Low'), - (2, 'Low'), - (3, 'Medium'), - (4, 'High'), - (5, 'Very High'), - ] - - IMPACT_CHOICES = [ - (1, 'Very Low'), - (2, 'Low'), - (3, 'Medium'), - (4, 'High'), - (5, 'Very High'), - ] - - RISK_LEVEL_CHOICES = [ - ('low', 'Low'), - ('medium', 'Medium'), - ('high', 'High'), - ('critical', 'Critical'), - ] - - CONTROL_EFFECTIVENESS_CHOICES = [ - ('poor', 'Poor'), - ('fair', 'Fair'), - ('good', 'Good'), - ('excellent', 'Excellent'), - ] - - STATUS_CHOICES = [ - ('draft', 'Draft'), - ('active', 'Active'), - ('under_review', 'Under Review'), - ('closed', 'Closed'), - ('cancelled', 'Cancelled'), - ] + + class RiskCategory(models.TextChoices): + CLINICAL = 'CLINICAL', 'Clinical' + OPERATIONAL = 'OPERATIONAL', 'Operational' + FINANCIAL = 'FINANCIAL', 'Financial' + REGULATORY = 'REGULATORY', 'Regulatory' + REPUTATIONAL = 'REPUTATIONAL', 'Reputational' + STRATEGIC = 'STRATEGIC', 'Strategic' + TECHNOLOGY = 'TECHNOLOGY', 'Technology' + ENVIRONMENTAL = 'ENVIRONMENTAL', 'Environmental' + SECURITY = 'SECURITY', 'Security' + LEGAL = 'LEGAL', 'Legal' + + class RiskType(models.TextChoices): + PATIENT_SAFETY = 'PATIENT_SAFETY', 'Patient Safety' + QUALITY = 'QUALITY', 'Quality' + COMPLIANCE = 'COMPLIANCE', 'Compliance' + FINANCIAL = 'FINANCIAL', 'Financial' + OPERATIONAL = 'OPERATIONAL', 'Operational' + STRATEGIC = 'STRATEGIC', 'Strategic' + TECHNOLOGY = 'TECHNOLOGY', 'Technology' + ENVIRONMENTAL = 'ENVIRONMENTAL', 'Environmental' + SECURITY = 'SECURITY', 'Security' + LEGAL = 'LEGAL', 'Legal' + + class Likelihood(models.IntegerChoices): + VERY_LOW = 1, 'Very Low' + LOW = 2, 'Low' + MEDIUM = 3, 'Medium' + HIGH = 4, 'High' + VERY_HIGH = 5, 'Very High' + + class Impact(models.IntegerChoices): + VERY_LOW = 1, 'Very Low' + LOW = 2, 'Low' + MEDIUM = 3, 'Medium' + HIGH = 4, 'High' + VERY_HIGH = 5, 'Very High' + + class RiskLevel(models.TextChoices): + LOW = 'LOW', 'Low' + MEDIUM = 'MEDIUM', 'Medium' + HIGH = 'HIGH', 'High' + CRITICAL = 'CRITICAL', 'Critical' + + class ControlEffectiveness(models.TextChoices): + POOR = 'POOR', 'Poor' + FAIR = 'FAIR', 'Fair' + GOOD = 'GOOD', 'Good' + EXCELLENT = 'EXCELLENT', 'Excellent' + + class RiskStatus(models.TextChoices): + DRAFT = 'DRAFT', 'Draft' + ACTIVE = 'ACTIVE', 'Active' + UNDER_REVIEW = 'UNDER_REVIEW', 'Under Review' + CLOSED = 'CLOSED', 'Closed' + CANCELLED = 'CANCELLED', 'Cancelled' tenant = models.ForeignKey('core.Tenant', on_delete=models.CASCADE, related_name='risk_assessments') title = models.CharField(max_length=200) description = models.TextField() - risk_category = models.CharField(max_length=20, choices=RISK_CATEGORY_CHOICES) - risk_type = models.CharField(max_length=20, choices=RISK_TYPE_CHOICES) - likelihood = models.IntegerField(choices=LIKELIHOOD_CHOICES) - impact = models.IntegerField(choices=IMPACT_CHOICES) + risk_category = models.CharField(max_length=20, choices=RiskCategory.choices) + risk_type = models.CharField(max_length=20, choices=RiskType.choices) + likelihood = models.IntegerField(choices=Likelihood.choices) + impact = models.IntegerField(choices=Impact.choices) risk_score = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(25)]) - risk_level = models.CharField(max_length=20, choices=RISK_LEVEL_CHOICES) + risk_level = models.CharField(max_length=20, choices=RiskLevel.choices) current_controls = models.TextField() - control_effectiveness = models.CharField(max_length=20, choices=CONTROL_EFFECTIVENESS_CHOICES) - # residual_likelihood = models.CharField(max_length=20, choices=LIKELIHOOD_CHOICES) - # residual_impact = models.CharField(max_length=20, choices=IMPACT_CHOICES) - # residual_risk_score = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(25)]) - # residual_risk_level = models.CharField(max_length=20, choices=RISK_LEVEL_CHOICES) + control_effectiveness = models.CharField(max_length=20, choices=ControlEffectiveness.choices) mitigation_plan = models.TextField() target_completion_date = models.DateTimeField() responsible_person = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='responsible_risks') review_date = models.DateField() review_notes = models.TextField(null=True, blank=True) - status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft') + status = models.CharField(max_length=20, choices=RiskStatus.choices, default=RiskStatus.DRAFT) incident_report = models.ForeignKey(IncidentReport, on_delete=models.SET_NULL, null=True, blank=True, related_name='risk_assessments') created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='created_risk_assessments') created_at = models.DateTimeField(auto_now_add=True) @@ -378,39 +367,36 @@ class RiskAssessment(models.Model): class AuditPlan(models.Model): """Quality audits and compliance monitoring plans""" - - AUDIT_TYPE_CHOICES = [ - ('internal', 'Internal'), - ('external', 'External'), - ('regulatory', 'Regulatory'), - ('accreditation', 'Accreditation'), - ('quality', 'Quality'), - ('compliance', 'Compliance'), - ('safety', 'Safety'), - ('operational', 'Operational'), - ('financial', 'Financial'), - ('clinical', 'Clinical'), - ] - - STATUS_CHOICES = [ - ('planned', 'Planned'), - ('in_progress', 'In Progress'), - ('completed', 'Completed'), - ('cancelled', 'Cancelled'), - ('postponed', 'Postponed'), - ] - - PRIORITY_CHOICES = [ - ('low', 'Low'), - ('medium', 'Medium'), - ('high', 'High'), - ('urgent', 'Urgent'), - ] + + class AuditType(models.TextChoices): + INTERNAL = 'INTERNAL', 'Internal' + EXTERNAL = 'EXTERNAL', 'External' + REGULATORY = 'REGULATORY', 'Regulatory' + ACCREDITATION = 'ACCREDITATION', 'Accreditation' + QUALITY = 'QUALITY', 'Quality' + COMPLIANCE = 'COMPLIANCE', 'Compliance' + SAFETY = 'SAFETY', 'Safety' + OPERATIONAL = 'OPERATIONAL', 'Operational' + FINANCIAL = 'FINANCIAL', 'Financial' + CLINICAL = 'CLINICAL', 'Clinical' + + class AuditStatus(models.TextChoices): + PLANNED = 'PLANNED', 'Planned' + IN_PROGRESS = 'IN_PROGRESS', 'In Progress' + COMPLETED = 'COMPLETED', 'Completed' + CANCELLED = 'CANCELLED', 'Cancelled' + POSTPONED = 'POSTPONED', 'Postponed' + + class AuditPriority(models.TextChoices): + LOW = 'LOW', 'Low' + MEDIUM = 'MEDIUM', 'Medium' + HIGH = 'HIGH', 'High' + URGENT = 'URGENT', 'Urgent' tenant = models.ForeignKey('core.Tenant', on_delete=models.CASCADE, related_name='audit_plans') title = models.CharField(max_length=200) description = models.TextField() - audit_type = models.CharField(max_length=20, choices=AUDIT_TYPE_CHOICES) + audit_type = models.CharField(max_length=20, choices=AuditType.choices) scope = models.TextField() criteria = models.TextField() department = models.ForeignKey('hr.Department', on_delete=models.SET_NULL, null=True, blank=True, related_name='audit_plans') @@ -420,8 +406,8 @@ class AuditPlan(models.Model): planned_end_date = models.DateField() actual_start_date = models.DateField(null=True, blank=True) actual_end_date = models.DateField(null=True, blank=True) - status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='planned') - priority = models.CharField(max_length=10, choices=PRIORITY_CHOICES, default='medium') + status = models.CharField(max_length=20, choices=AuditStatus.choices, default=AuditStatus.PLANNED) + priority = models.CharField(max_length=10, choices=AuditPriority.choices, default=AuditPriority.MEDIUM) regulatory_requirement = models.BooleanField(default=False) accreditation_body = models.CharField(max_length=200, blank=True) objectives = models.TextField(blank=True) @@ -455,51 +441,47 @@ class AuditPlan(models.Model): class AuditFinding(models.Model): """Audit findings, non-conformities, and observations""" - - FINDING_TYPE_CHOICES = [ - ('non_conformity', 'Non-Conformity'), - ('observation', 'Observation'), - ('opportunity_for_improvement', 'Opportunity for Improvement'), - ('positive_finding', 'Positive Finding'), - ('recommendation', 'Recommendation'), - ] - - SEVERITY_CHOICES = [ - ('minor', 'Minor'), - ('major', 'Major'), - ('critical', 'Critical'), - ] - - CATEGORY_CHOICES = [ - ('documentation', 'Documentation'), - ('process', 'Process'), - ('training', 'Training'), - ('equipment', 'Equipment'), - ('environment', 'Environment'), - ('management', 'Management'), - ('communication', 'Communication'), - ('safety', 'Safety'), - ('quality', 'Quality'), - ('compliance', 'Compliance'), - ] - - STATUS_CHOICES = [ - ('open', 'Open'), - ('in_progress', 'In Progress'), - ('completed', 'Completed'), - ('verified', 'Verified'), - ('closed', 'Closed'), - ] + + class FindingType(models.TextChoices): + NON_CONFORMITY = 'NON_CONFORMITY', 'Non-Conformity' + OBSERVATION = 'OBSERVATION', 'Observation' + OPPORTUNITY_FOR_IMPROVEMENT = 'OPPORTUNITY_FOR_IMPROVEMENT', 'Opportunity for Improvement' + POSITIVE_FINDING = 'POSITIVE_FINDING', 'Positive Finding' + RECOMMENDATION = 'RECOMMENDATION', 'Recommendation' + + class FindingSeverity(models.TextChoices): + MINOR = 'MINOR', 'Minor' + MAJOR = 'MAJOR', 'Major' + CRITICAL = 'CRITICAL', 'Critical' + + class FindingCategory(models.TextChoices): + DOCUMENTATION = 'DOCUMENTATION', 'Documentation' + PROCESS = 'PROCESS', 'Process' + TRAINING = 'TRAINING', 'Training' + EQUIPMENT = 'EQUIPMENT', 'Equipment' + ENVIRONMENT = 'ENVIRONMENT', 'Environment' + MANAGEMENT = 'MANAGEMENT', 'Management' + COMMUNICATION = 'COMMUNICATION', 'Communication' + SAFETY = 'SAFETY', 'Safety' + QUALITY = 'QUALITY', 'Quality' + COMPLIANCE = 'COMPLIANCE', 'Compliance' + + class FindingStatus(models.TextChoices): + OPEN = 'OPEN', 'Open' + IN_PROGRESS = 'IN_PROGRESS', 'In Progress' + COMPLETED = 'COMPLETED', 'Completed' + VERIFIED = 'VERIFIED', 'Verified' + CLOSED = 'CLOSED', 'Closed' tenant = models.ForeignKey('core.Tenant', on_delete=models.CASCADE, related_name='audit_findings') audit_plan = models.ForeignKey(AuditPlan, on_delete=models.CASCADE, related_name='findings') finding_number = models.CharField(max_length=50) title = models.CharField(max_length=200) description = models.TextField() - finding_type = models.CharField(max_length=30, choices=FINDING_TYPE_CHOICES) + finding_type = models.CharField(max_length=30, choices=FindingType.choices) finding_date = models.DateField() - severity = models.CharField(max_length=20, choices=SEVERITY_CHOICES) - category = models.CharField(max_length=20, choices=CATEGORY_CHOICES) + severity = models.CharField(max_length=20, choices=FindingSeverity.choices) + category = models.CharField(max_length=20, choices=FindingCategory.choices) criteria_reference = models.CharField(max_length=200) evidence = models.TextField() root_cause = models.TextField(blank=True) @@ -508,7 +490,7 @@ class AuditFinding(models.Model): responsible_person = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='responsible_findings') target_completion_date = models.DateField(null=True, blank=True) actual_completion_date = models.DateField(null=True, blank=True) - status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='open') + status = models.CharField(max_length=20, choices=FindingStatus.choices, default=FindingStatus.OPEN) verification_method = models.CharField(max_length=200, blank=True) verified_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='verified_findings') verified_date = models.DateField(null=True, blank=True) @@ -539,56 +521,52 @@ class AuditFinding(models.Model): class ImprovementProject(models.Model): """Quality improvement projects and initiatives""" - - PROJECT_TYPE_CHOICES = [ - ('quality_improvement', 'Quality Improvement'), - ('process_improvement', 'Process Improvement'), - ('safety_initiative', 'Safety Initiative'), - ('compliance_project', 'Compliance Project'), - ('cost_reduction', 'Cost Reduction'), - ('efficiency_improvement', 'Efficiency Improvement'), - ('patient_satisfaction', 'Patient Satisfaction'), - ('staff_satisfaction', 'Staff Satisfaction'), - ('technology_implementation', 'Technology Implementation'), - ('training_program', 'Training Program'), - ] - - METHODOLOGY_CHOICES = [ - ('pdsa', 'PDSA (Plan-Do-Study-Act)'), - ('lean', 'Lean'), - ('six_sigma', 'Six Sigma'), - ('kaizen', 'Kaizen'), - ('root_cause_analysis', 'Root Cause Analysis'), - ('failure_mode_analysis', 'Failure Mode Analysis'), - ('other', 'Other'), - ] - - STATUS_CHOICES = [ - ('planned', 'Planned'), - ('active', 'Active'), - ('on_hold', 'On Hold'), - ('completed', 'Completed'), - ('cancelled', 'Cancelled'), - ] - - PHASE_CHOICES = [ - ('define', 'Define'), - ('measure', 'Measure'), - ('analyze', 'Analyze'), - ('improve', 'Improve'), - ('control', 'Control'), - ('plan', 'Plan'), - ('do', 'Do'), - ('study', 'Study'), - ('act', 'Act'), - ] + + class ProjectType(models.TextChoices): + QUALITY_IMPROVEMENT = 'QUALITY_IMPROVEMENT', 'Quality Improvement' + PROCESS_IMPROVEMENT = 'PROCESS_IMPROVEMENT', 'Process Improvement' + SAFETY_INITIATIVE = 'SAFETY_INITIATIVE', 'Safety Initiative' + COMPLIANCE_PROJECT = 'COMPLIANCE_PROJECT', 'Compliance Project' + COST_REDUCTION = 'COST_REDUCTION', 'Cost Reduction' + EFFICIENCY_IMPROVEMENT = 'EFFICIENCY_IMPROVEMENT', 'Efficiency Improvement' + PATIENT_SATISFACTION = 'PATIENT_SATISFACTION', 'Patient Satisfaction' + STAFF_SATISFACTION = 'STAFF_SATISFACTION', 'Staff Satisfaction' + TECHNOLOGY_IMPLEMENTATION = 'TECHNOLOGY_IMPLEMENTATION', 'Technology Implementation' + TRAINING_PROGRAM = 'TRAINING_PROGRAM', 'Training Program' + + class Methodology(models.TextChoices): + PDSA = 'PDSA', 'PDSA (Plan-Do-Study-Act)' + LEAN = 'LEAN', 'Lean' + SIX_SIGMA = 'SIX_SIGMA', 'Six Sigma' + KAIZEN = 'KAIZEN', 'Kaizen' + ROOT_CAUSE_ANALYSIS = 'ROOT_CAUSE_ANALYSIS', 'Root Cause Analysis' + FAILURE_MODE_ANALYSIS = 'FAILURE_MODE_ANALYSIS', 'Failure Mode Analysis' + OTHER = 'OTHER', 'Other' + + class ProjectStatus(models.TextChoices): + PLANNED = 'PLANNED', 'Planned' + ACTIVE = 'ACTIVE', 'Active' + ON_HOLD = 'ON_HOLD', 'On Hold' + COMPLETED = 'COMPLETED', 'Completed' + CANCELLED = 'CANCELLED', 'Cancelled' + + class ProjectPhase(models.TextChoices): + DEFINE = 'DEFINE', 'Define' + MEASURE = 'MEASURE', 'Measure' + ANALYZE = 'ANALYZE', 'Analyze' + IMPROVE = 'IMPROVE', 'Improve' + CONTROL = 'CONTROL', 'Control' + PLAN = 'PLAN', 'Plan' + DO = 'DO', 'Do' + STUDY = 'STUDY', 'Study' + ACT = 'ACT', 'Act' tenant = models.ForeignKey('core.Tenant', on_delete=models.CASCADE, related_name='improvement_projects') project_number = models.CharField(max_length=50, unique=True) title = models.CharField(max_length=200) description = models.TextField() - project_type = models.CharField(max_length=30, choices=PROJECT_TYPE_CHOICES) - methodology = models.CharField(max_length=30, choices=METHODOLOGY_CHOICES) + project_type = models.CharField(max_length=30, choices=ProjectType.choices) + methodology = models.CharField(max_length=30, choices=Methodology.choices) problem_statement = models.TextField() goal_statement = models.TextField() success_metrics = models.TextField() @@ -604,8 +582,8 @@ class ImprovementProject(models.Model): planned_end_date = models.DateField() actual_start_date = models.DateField(null=True, blank=True) actual_end_date = models.DateField(null=True, blank=True) - status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='planned') - phase = models.CharField(max_length=20, choices=PHASE_CHOICES, default='define') + status = models.CharField(max_length=20, choices=ProjectStatus.choices, default=ProjectStatus.PLANNED) + phase = models.CharField(max_length=20, choices=ProjectPhase.choices, default=ProjectPhase.DEFINE) estimated_cost = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) resources_required = models.TextField(blank=True) actual_cost = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) diff --git a/radiology/.DS_Store b/radiology/.DS_Store new file mode 100644 index 00000000..088c5ddc Binary files /dev/null and b/radiology/.DS_Store differ diff --git a/radiology/__pycache__/admin.cpython-312.pyc b/radiology/__pycache__/admin.cpython-312.pyc index 6eac023c..92a24b10 100644 Binary files a/radiology/__pycache__/admin.cpython-312.pyc and b/radiology/__pycache__/admin.cpython-312.pyc differ diff --git a/radiology/__pycache__/constants.cpython-312.pyc b/radiology/__pycache__/constants.cpython-312.pyc new file mode 100644 index 00000000..688b376e Binary files /dev/null and b/radiology/__pycache__/constants.cpython-312.pyc differ diff --git a/radiology/__pycache__/forms.cpython-312.pyc b/radiology/__pycache__/forms.cpython-312.pyc index 23e9d722..e8a6cba4 100644 Binary files a/radiology/__pycache__/forms.cpython-312.pyc and b/radiology/__pycache__/forms.cpython-312.pyc differ diff --git a/radiology/__pycache__/managers.cpython-312.pyc b/radiology/__pycache__/managers.cpython-312.pyc new file mode 100644 index 00000000..89009af3 Binary files /dev/null and b/radiology/__pycache__/managers.cpython-312.pyc differ diff --git a/radiology/__pycache__/models.cpython-312.pyc b/radiology/__pycache__/models.cpython-312.pyc index 7f96b68f..506a9379 100644 Binary files a/radiology/__pycache__/models.cpython-312.pyc and b/radiology/__pycache__/models.cpython-312.pyc differ diff --git a/radiology/__pycache__/services.cpython-312.pyc b/radiology/__pycache__/services.cpython-312.pyc new file mode 100644 index 00000000..d9300b31 Binary files /dev/null and b/radiology/__pycache__/services.cpython-312.pyc differ diff --git a/radiology/__pycache__/urls.cpython-312.pyc b/radiology/__pycache__/urls.cpython-312.pyc index b0ecf794..830b7763 100644 Binary files a/radiology/__pycache__/urls.cpython-312.pyc and b/radiology/__pycache__/urls.cpython-312.pyc differ diff --git a/radiology/__pycache__/views.cpython-312.pyc b/radiology/__pycache__/views.cpython-312.pyc index 6b83715c..4a520b05 100644 Binary files a/radiology/__pycache__/views.cpython-312.pyc and b/radiology/__pycache__/views.cpython-312.pyc differ diff --git a/radiology/admin.py b/radiology/admin.py index 7bc24b6f..93a0cf74 100644 --- a/radiology/admin.py +++ b/radiology/admin.py @@ -45,12 +45,12 @@ class ImagingStudyAdmin(admin.ModelAdmin): """ list_display = [ 'accession_number', 'patient_name', 'modality', - 'study_description', 'study_date', 'status', + 'study_description', 'study_datetime', 'status', 'priority', 'radiologist', 'number_of_series' ] list_filter = [ 'tenant', 'modality', 'status', 'priority', - 'study_date', 'image_quality', 'completion_status' + 'study_datetime', 'image_quality', 'completion_status' ] search_fields = [ 'accession_number', 'study_instance_uid', @@ -71,8 +71,7 @@ class ImagingStudyAdmin(admin.ModelAdmin): }), ('Study Details', { 'fields': [ - 'modality', 'study_description', 'body_part', - 'study_date', 'study_time', 'study_datetime' + 'modality', 'study_description', 'body_part', 'study_datetime' ] }), ('Clinical Information', { @@ -124,7 +123,7 @@ class ImagingStudyAdmin(admin.ModelAdmin): }) ] inlines = [ImagingSeriesInline] - date_hierarchy = 'study_date' + date_hierarchy = 'study_datetime' def patient_name(self, obj): """Display patient name.""" @@ -146,11 +145,11 @@ class ImagingSeriesAdmin(admin.ModelAdmin): """ list_display = [ 'series_number', 'study_accession', 'modality', - 'series_description', 'series_date', 'number_of_instances', + 'series_description', 'series_datetime', 'number_of_instances', 'image_quality' ] list_filter = [ - 'modality', 'series_date', 'image_quality', + 'modality', 'series_datetime', 'image_quality', 'patient_position', 'contrast_route' ] search_fields = [ diff --git a/radiology/constants.py b/radiology/constants.py new file mode 100644 index 00000000..9b24ed5f --- /dev/null +++ b/radiology/constants.py @@ -0,0 +1,189 @@ +""" +Constants for radiology app. +Centralized choices and validators for consistency across models. +""" + +from django.db import models +from django.core.validators import RegexValidator + + +class RadiologyChoices: + """Centralized choices for radiology models.""" + + class Modality(models.TextChoices): + CR = 'CR', 'Computed Radiography' + CT = 'CT', 'Computed Tomography' + MR = 'MR', 'Magnetic Resonance' + US = 'US', 'Ultrasound' + XA = 'XA', 'X-Ray Angiography' + RF = 'RF', 'Radiofluoroscopy' + DX = 'DX', 'Digital Radiography' + MG = 'MG', 'Mammography' + NM = 'NM', 'Nuclear Medicine' + PT = 'PT', 'Positron Emission Tomography' + OT = 'OT', 'Other' + + class BodyPart(models.TextChoices): + HEAD = 'HEAD', 'Head' + NECK = 'NECK', 'Neck' + CHEST = 'CHEST', 'Chest' + ABDOMEN = 'ABDOMEN', 'Abdomen' + PELVIS = 'PELVIS', 'Pelvis' + SPINE = 'SPINE', 'Spine' + EXTREMITY = 'EXTREMITY', 'Extremity' + BREAST = 'BREAST', 'Breast' + HEART = 'HEART', 'Heart' + BRAIN = 'BRAIN', 'Brain' + WHOLE_BODY = 'WHOLE_BODY', 'Whole Body' + OTHER = 'OTHER', 'Other' + + class Priority(models.TextChoices): + ROUTINE = 'ROUTINE', 'Routine' + URGENT = 'URGENT', 'Urgent' + STAT = 'STAT', 'STAT' + EMERGENCY = 'EMERGENCY', 'Emergency' + + class ImageQuality(models.TextChoices): + EXCELLENT = 'EXCELLENT', 'Excellent' + GOOD = 'GOOD', 'Good' + ADEQUATE = 'ADEQUATE', 'Adequate' + POOR = 'POOR', 'Poor' + UNACCEPTABLE = 'UNACCEPTABLE', 'Unacceptable' + + class StudyStatus(models.TextChoices): + SCHEDULED = 'SCHEDULED', 'Scheduled' + ARRIVED = 'ARRIVED', 'Arrived' + IN_PROGRESS = 'IN_PROGRESS', 'In Progress' + COMPLETED = 'COMPLETED', 'Completed' + INTERPRETED = 'INTERPRETED', 'Interpreted' + FINALIZED = 'FINALIZED', 'Finalized' + CANCELLED = 'CANCELLED', 'Cancelled' + + class OrderStatus(models.TextChoices): + PENDING = 'PENDING', 'Pending' + SCHEDULED = 'SCHEDULED', 'Scheduled' + IN_PROGRESS = 'IN_PROGRESS', 'In Progress' + COMPLETED = 'COMPLETED', 'Completed' + CANCELLED = 'CANCELLED', 'Cancelled' + ON_HOLD = 'ON_HOLD', 'On Hold' + + class ReportStatus(models.TextChoices): + DRAFT = 'DRAFT', 'Draft' + PRELIMINARY = 'PRELIMINARY', 'Preliminary' + FINAL = 'FINAL', 'Final' + AMENDED = 'AMENDED', 'Amended' + CORRECTED = 'CORRECTED', 'Corrected' + + class CompletionStatus(models.TextChoices): + COMPLETE = 'COMPLETE', 'Complete' + PARTIAL = 'PARTIAL', 'Partial' + INCOMPLETE = 'INCOMPLETE', 'Incomplete' + + class PatientPosition(models.TextChoices): + HFP = 'HFP', 'Head First-Prone' + HFS = 'HFS', 'Head First-Supine' + HFDR = 'HFDR', 'Head First-Decubitus Right' + HFDL = 'HFDL', 'Head First-Decubitus Left' + FFP = 'FFP', 'Feet First-Prone' + FFS = 'FFS', 'Feet First-Supine' + FFDR = 'FFDR', 'Feet First-Decubitus Right' + FFDL = 'FFDL', 'Feet First-Decubitus Left' + + class ContrastRoute(models.TextChoices): + IV = 'IV', 'Intravenous' + ORAL = 'ORAL', 'Oral' + RECTAL = 'RECTAL', 'Rectal' + INTRATHECAL = 'INTRATHECAL', 'Intrathecal' + INTRA_ARTICULAR = 'INTRA_ARTICULAR', 'Intra-articular' + OTHER = 'OTHER', 'Other' + + +class RadiologyValidators: + """Validators for radiology-specific fields.""" + + # DICOM UID validator - must be numeric with dots + dicom_uid_validator = RegexValidator( + regex=r'^[0-9]+(\.[0-9]+)*$', + message='Invalid DICOM UID format. Must contain only numbers and dots.' + ) + + # Accession number validator + accession_number_validator = RegexValidator( + regex=r'^[A-Z]{2,4}-\d{8}-\d{4}$', + message='Invalid accession number format. Expected format: RAD-YYYYMMDD-NNNN' + ) + + # Order number validator + order_number_validator = RegexValidator( + regex=r'^[A-Z]{3}-\d+-\d{6}$', + message='Invalid order number format. Expected format: IMG-TENANT-NNNNNN' + ) + + +# DICOM Standard Constants +class DICOMConstants: + """DICOM standard constants and mappings.""" + + # Common SOP Class UIDs + SOP_CLASS_UIDS = { + 'CT_IMAGE': '1.2.840.10008.5.1.4.1.1.2', + 'MR_IMAGE': '1.2.840.10008.5.1.4.1.1.4', + 'US_IMAGE': '1.2.840.10008.5.1.4.1.1.6.1', + 'CR_IMAGE': '1.2.840.10008.5.1.4.1.1.1', + 'DX_IMAGE': '1.2.840.10008.5.1.4.1.1.1.1', + } + + # Transfer Syntax UIDs + TRANSFER_SYNTAX_UIDS = { + 'IMPLICIT_VR_LITTLE_ENDIAN': '1.2.840.10008.1.2', + 'EXPLICIT_VR_LITTLE_ENDIAN': '1.2.840.10008.1.2.1', + 'EXPLICIT_VR_BIG_ENDIAN': '1.2.840.10008.1.2.2', + 'JPEG_BASELINE': '1.2.840.10008.1.2.4.50', + 'JPEG_LOSSLESS': '1.2.840.10008.1.2.4.70', + } + + # Standard image types + IMAGE_TYPES = { + 'ORIGINAL': 'Original image', + 'DERIVED': 'Derived image', + 'SECONDARY': 'Secondary capture', + } + + +# Business Rules Constants +class RadiologyBusinessRules: + """Business rules and constraints for radiology workflow.""" + + # Status transition rules + VALID_STATUS_TRANSITIONS = { + 'SCHEDULED': ['ARRIVED', 'CANCELLED'], + 'ARRIVED': ['IN_PROGRESS', 'CANCELLED'], + 'IN_PROGRESS': ['COMPLETED', 'CANCELLED'], + 'COMPLETED': ['INTERPRETED'], + 'INTERPRETED': ['FINALIZED'], + 'FINALIZED': ['AMENDED'], + 'CANCELLED': [], # Terminal state + } + + # Priority escalation rules + PRIORITY_ESCALATION_HOURS = { + 'ROUTINE': 24, + 'URGENT': 4, + 'STAT': 1, + 'EMERGENCY': 0.5, + } + + # Critical finding communication requirements + CRITICAL_COMMUNICATION_TIMEFRAMES = { + 'EMERGENCY': 15, # minutes + 'STAT': 30, # minutes + 'URGENT': 60, # minutes + 'ROUTINE': 120, # minutes + } + + # Quality thresholds + QUALITY_THRESHOLDS = { + 'MIN_ACCEPTABLE': 'ADEQUATE', + 'PREFERRED': 'GOOD', + 'EXCELLENT': 'EXCELLENT', + } diff --git a/radiology/forms.py b/radiology/forms.py index f629c98f..4d69578f 100644 --- a/radiology/forms.py +++ b/radiology/forms.py @@ -246,18 +246,18 @@ class RadiologyReportForm(forms.ModelForm): is_active=True ).order_by('template_name') - # Make critical findings description required if has_critical_findings is True - if self.data and self.data.get('has_critical_findings'): + # Make critical findings description required if critical_finding is True + if self.data and self.data.get('critical_finding'): self.fields['critical_findings_description'].required = True def clean(self): cleaned_data = super().clean() - has_critical_findings = cleaned_data.get('has_critical_findings') + critical_finding = cleaned_data.get('critical_finding') critical_findings_description = cleaned_data.get('critical_findings_description') study = cleaned_data.get('study') # Validate critical findings - if has_critical_findings and not critical_findings_description: + if critical_finding and not critical_findings_description: raise ValidationError('Critical findings description is required when critical findings are present.') # Check if report already exists for this study @@ -479,4 +479,3 @@ class ReportSigningForm(forms.Form): raise ValidationError('Invalid electronic signature.') return signature - diff --git a/radiology/management/__init__.py b/radiology/management/__init__.py new file mode 100644 index 00000000..65cb83ac --- /dev/null +++ b/radiology/management/__init__.py @@ -0,0 +1 @@ +# Management commands package diff --git a/radiology/management/__pycache__/__init__.cpython-312.pyc b/radiology/management/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..a7cf108d Binary files /dev/null and b/radiology/management/__pycache__/__init__.cpython-312.pyc differ diff --git a/radiology/management/commands/__init__.py b/radiology/management/commands/__init__.py new file mode 100644 index 00000000..2c1c7c15 --- /dev/null +++ b/radiology/management/commands/__init__.py @@ -0,0 +1 @@ +# Management commands diff --git a/radiology/management/commands/__pycache__/__init__.cpython-312.pyc b/radiology/management/commands/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..e4657b31 Binary files /dev/null and b/radiology/management/commands/__pycache__/__init__.cpython-312.pyc differ diff --git a/radiology/management/commands/__pycache__/generate_dicom.cpython-312.pyc b/radiology/management/commands/__pycache__/generate_dicom.cpython-312.pyc new file mode 100644 index 00000000..721efc0a Binary files /dev/null and b/radiology/management/commands/__pycache__/generate_dicom.cpython-312.pyc differ diff --git a/radiology/management/commands/dicom_utils.py b/radiology/management/commands/dicom_utils.py new file mode 100644 index 00000000..8296656c --- /dev/null +++ b/radiology/management/commands/dicom_utils.py @@ -0,0 +1,352 @@ +""" +Django management command for DICOM utilities and operations. +""" + +import os +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone +from pydicom import dcmread +from pydicom.uid import generate_uid + +from radiology.models import DICOMImage, ImagingSeries, ImagingStudy +from radiology.services import DICOMGenerator, DICOMValidator, DICOMUtilities + + +class Command(BaseCommand): + help = 'DICOM utilities and operations' + + def add_arguments(self, parser): + parser.add_argument( + '--inspect-file', + type=str, + help='Inspect a DICOM file and show metadata' + ) + parser.add_argument( + '--import-dicom', + type=str, + help='Import existing DICOM file into database' + ) + parser.add_argument( + '--export-metadata', + type=str, + help='Export DICOM metadata to JSON file' + ) + parser.add_argument( + '--generate-uids', + type=int, + help='Generate specified number of DICOM UIDs' + ) + parser.add_argument( + '--cleanup-orphaned', + action='store_true', + help='Clean up orphaned DICOM files' + ) + parser.add_argument( + '--verify-integrity', + action='store_true', + help='Verify integrity of all DICOM files' + ) + + def handle(self, *args, **options): + try: + if options['inspect_file']: + self._inspect_dicom_file(options['inspect_file']) + elif options['import_dicom']: + self._import_dicom_file(options['import_dicom']) + elif options['export_metadata']: + self._export_metadata(options['export_metadata']) + elif options['generate_uids']: + self._generate_uids(options['generate_uids']) + elif options['cleanup_orphaned']: + self._cleanup_orphaned_files() + elif options['verify_integrity']: + self._verify_integrity() + else: + self.stdout.write("Please specify an operation. Use --help for options.") + + except Exception as e: + raise CommandError(f'Error in DICOM utilities: {str(e)}') + + def _inspect_dicom_file(self, file_path): + """Inspect a DICOM file and display metadata.""" + if not os.path.exists(file_path): + raise CommandError(f'File not found: {file_path}') + + self.stdout.write(f"Inspecting DICOM file: {file_path}") + self.stdout.write("-" * 60) + + try: + ds = dcmread(file_path) + + # Basic information + self.stdout.write(f"Patient Name: {getattr(ds, 'PatientName', 'N/A')}") + self.stdout.write(f"Patient ID: {getattr(ds, 'PatientID', 'N/A')}") + self.stdout.write(f"Study Date: {getattr(ds, 'StudyDate', 'N/A')}") + self.stdout.write(f"Study Description: {getattr(ds, 'StudyDescription', 'N/A')}") + self.stdout.write(f"Modality: {getattr(ds, 'Modality', 'N/A')}") + self.stdout.write(f"Series Number: {getattr(ds, 'SeriesNumber', 'N/A')}") + self.stdout.write(f"Instance Number: {getattr(ds, 'InstanceNumber', 'N/A')}") + + # Image dimensions + self.stdout.write(f"Rows: {getattr(ds, 'Rows', 'N/A')}") + self.stdout.write(f"Columns: {getattr(ds, 'Columns', 'N/A')}") + self.stdout.write(f"Bits Allocated: {getattr(ds, 'BitsAllocated', 'N/A')}") + self.stdout.write(f"Bits Stored: {getattr(ds, 'BitsStored', 'N/A')}") + + # UIDs + self.stdout.write(f"Study Instance UID: {getattr(ds, 'StudyInstanceUID', 'N/A')}") + self.stdout.write(f"Series Instance UID: {getattr(ds, 'SeriesInstanceUID', 'N/A')}") + self.stdout.write(f"SOP Instance UID: {getattr(ds, 'SOPInstanceUID', 'N/A')}") + self.stdout.write(f"SOP Class UID: {getattr(ds, 'SOPClassUID', 'N/A')}") + + # File information + file_size = os.path.getsize(file_path) + self.stdout.write(f"File Size: {file_size} bytes ({file_size / (1024*1024):.2f} MB)") + + # Validation + validation = DICOMValidator.validate_dicom_file(file_path) + if validation['valid']: + self.stdout.write(self.style.SUCCESS("✓ File is valid DICOM")) + else: + self.stdout.write(self.style.ERROR(f"✗ File validation failed: {validation['errors']}")) + + except Exception as e: + self.stdout.write(self.style.ERROR(f"Error reading DICOM file: {str(e)}")) + + def _import_dicom_file(self, file_path): + """Import existing DICOM file into database.""" + if not os.path.exists(file_path): + raise CommandError(f'File not found: {file_path}') + + self.stdout.write(f"Importing DICOM file: {file_path}") + + try: + ds = dcmread(file_path) + + # Extract metadata + study_uid = str(ds.StudyInstanceUID) + series_uid = str(ds.SeriesInstanceUID) + sop_uid = str(ds.SOPInstanceUID) + + # Check if image already exists + if DICOMImage.objects.filter(sop_instance_uid=sop_uid).exists(): + self.stdout.write(f"DICOM image already exists: {sop_uid}") + return + + # Find or create study + try: + study = ImagingStudy.objects.get(study_instance_uid=study_uid) + except ImagingStudy.DoesNotExist: + self.stdout.write(f"Study not found in database: {study_uid}") + return + + # Find or create series + series, created = ImagingSeries.objects.get_or_create( + series_instance_uid=series_uid, + defaults={ + 'study': study, + 'series_number': int(getattr(ds, 'SeriesNumber', 1)), + 'modality': str(getattr(ds, 'Modality', 'CT')), + 'series_description': str(getattr(ds, 'SeriesDescription', '')), + 'series_datetime': timezone.now(), + } + ) + + if created: + self.stdout.write(f"Created new series: {series_uid}") + + # Create DICOM image record + dicom_image = DICOMImage.objects.create( + series=series, + sop_instance_uid=sop_uid, + instance_number=int(getattr(ds, 'InstanceNumber', 1)), + sop_class_uid=str(getattr(ds, 'SOPClassUID', '')), + rows=int(getattr(ds, 'Rows', 512)), + columns=int(getattr(ds, 'Columns', 512)), + bits_allocated=int(getattr(ds, 'BitsAllocated', 16)), + bits_stored=int(getattr(ds, 'BitsStored', 16)), + file_path=file_path, + file_size=os.path.getsize(file_path), + window_center=float(getattr(ds, 'WindowCenter', 0)) if hasattr(ds, 'WindowCenter') else None, + window_width=float(getattr(ds, 'WindowWidth', 0)) if hasattr(ds, 'WindowWidth') else None, + ) + + self.stdout.write( + self.style.SUCCESS(f"Successfully imported DICOM image: {sop_uid}") + ) + + except Exception as e: + self.stdout.write(self.style.ERROR(f"Error importing DICOM file: {str(e)}")) + + def _export_metadata(self, output_file): + """Export DICOM metadata to JSON file.""" + import json + + self.stdout.write("Exporting DICOM metadata...") + + metadata = [] + for image in DICOMImage.objects.all(): + image_data = { + 'image_id': str(image.image_id), + 'sop_instance_uid': image.sop_instance_uid, + 'instance_number': image.instance_number, + 'rows': image.rows, + 'columns': image.columns, + 'bits_allocated': image.bits_allocated, + 'file_path': image.file_path, + 'file_size': image.file_size, + 'series': { + 'series_id': str(image.series.series_id), + 'series_instance_uid': image.series.series_instance_uid, + 'series_number': image.series.series_number, + 'modality': image.series.modality, + 'series_description': image.series.series_description, + }, + 'study': { + 'study_id': str(image.study.study_id), + 'study_instance_uid': image.study.study_instance_uid, + 'accession_number': image.study.accession_number, + 'study_description': image.study.study_description, + }, + 'patient': { + 'patient_id': image.patient.patient_id, + 'first_name': image.patient.first_name, + 'last_name': image.patient.last_name, + } + } + metadata.append(image_data) + + with open(output_file, 'w') as f: + json.dump(metadata, f, indent=2, default=str) + + self.stdout.write( + self.style.SUCCESS(f"Exported metadata for {len(metadata)} images to {output_file}") + ) + + def _generate_uids(self, count): + """Generate DICOM UIDs.""" + self.stdout.write(f"Generating {count} DICOM UIDs:") + self.stdout.write("-" * 60) + + for i in range(count): + uid = generate_uid() + self.stdout.write(f"{i+1:3d}: {uid}") + + def _cleanup_orphaned_files(self): + """Clean up orphaned DICOM files.""" + self.stdout.write("Cleaning up orphaned DICOM files...") + + generator = DICOMGenerator() + dicom_dir = generator.dicom_storage_path + + if not os.path.exists(dicom_dir): + self.stdout.write("DICOM storage directory does not exist.") + return + + # Get all file paths from database + db_file_paths = set( + DICOMImage.objects.filter(file_path__isnull=False) + .exclude(file_path='') + .values_list('file_path', flat=True) + ) + + # Find all DICOM files on disk + disk_files = [] + for root, dirs, files in os.walk(dicom_dir): + for file in files: + if file.endswith('.dcm'): + disk_files.append(os.path.join(root, file)) + + # Find orphaned files + orphaned_files = [f for f in disk_files if f not in db_file_paths] + + if not orphaned_files: + self.stdout.write("No orphaned DICOM files found.") + return + + self.stdout.write(f"Found {len(orphaned_files)} orphaned files:") + for file_path in orphaned_files: + self.stdout.write(f" {file_path}") + + # Ask for confirmation + confirm = input("Delete these files? (y/N): ") + if confirm.lower() == 'y': + deleted_count = 0 + for file_path in orphaned_files: + try: + os.remove(file_path) + deleted_count += 1 + except Exception as e: + self.stdout.write(self.style.ERROR(f"Error deleting {file_path}: {str(e)}")) + + self.stdout.write( + self.style.SUCCESS(f"Deleted {deleted_count} orphaned files") + ) + else: + self.stdout.write("Cleanup cancelled.") + + def _verify_integrity(self): + """Verify integrity of all DICOM files.""" + self.stdout.write("Verifying integrity of all DICOM files...") + + images = DICOMImage.objects.all() + total_images = images.count() + + if total_images == 0: + self.stdout.write("No DICOM images found.") + return + + valid_count = 0 + invalid_count = 0 + missing_count = 0 + + for image in images: + if not image.file_path: + missing_count += 1 + self.stdout.write(f"⚠ No file path: {image.sop_instance_uid}") + continue + + if not image.has_dicom_file(): + missing_count += 1 + self.stdout.write(f"⚠ Missing file: {image.file_path}") + continue + + # Validate file + validation = DICOMValidator.validate_dicom_file(image.file_path) + if validation['valid']: + valid_count += 1 + + # Check if metadata matches + if validation['sop_instance_uid'] != image.sop_instance_uid: + self.stdout.write( + self.style.WARNING( + f"⚠ UID mismatch: {image.sop_instance_uid} vs {validation['sop_instance_uid']}" + ) + ) + + if validation['file_size'] != image.file_size: + self.stdout.write( + self.style.WARNING( + f"⚠ Size mismatch: {image.file_size} vs {validation['file_size']}" + ) + ) + else: + invalid_count += 1 + self.stdout.write( + self.style.ERROR( + f"✗ Invalid: {image.sop_instance_uid} - {validation['errors']}" + ) + ) + + self.stdout.write("-" * 60) + self.stdout.write(f"Total images: {total_images}") + self.stdout.write(f"Valid files: {valid_count}") + self.stdout.write(f"Invalid files: {invalid_count}") + self.stdout.write(f"Missing files: {missing_count}") + + if invalid_count == 0 and missing_count == 0: + self.stdout.write(self.style.SUCCESS("✓ All DICOM files are valid and present")) + else: + self.stdout.write( + self.style.WARNING(f"⚠ Found {invalid_count + missing_count} issues") + ) diff --git a/radiology/management/commands/generate_dicom.py b/radiology/management/commands/generate_dicom.py new file mode 100644 index 00000000..41ae2f50 --- /dev/null +++ b/radiology/management/commands/generate_dicom.py @@ -0,0 +1,366 @@ +""" +Django management command for generating DICOM files from DICOMImage model data. +""" + +import os +import numpy as np +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone +from django.db import models +from pydicom.uid import generate_uid + +from radiology.models import DICOMImage, ImagingSeries, ImagingStudy +from radiology.services import DICOMGenerator, DICOMValidator, DICOMUtilities +from patients.models import PatientProfile +from core.models import Tenant +from accounts.models import User + + +class Command(BaseCommand): + help = 'Generate DICOM files from DICOMImage model data' + + def add_arguments(self, parser): + parser.add_argument( + '--image-id', + type=str, + help='Generate DICOM for specific DICOMImage ID' + ) + parser.add_argument( + '--series-id', + type=str, + help='Generate DICOMs for all images in a series' + ) + parser.add_argument( + '--study-id', + type=str, + help='Generate DICOMs for all images in a study' + ) + parser.add_argument( + '--create-test-data', + action='store_true', + help='Create test study with DICOM files' + ) + parser.add_argument( + '--modality', + type=str, + default='CT', + choices=['CT', 'MR', 'CR', 'DX', 'US', 'XA', 'RF', 'MG', 'NM', 'PT'], + help='Modality for test data (default: CT)' + ) + parser.add_argument( + '--num-images', + type=int, + default=10, + help='Number of images to generate for test data (default: 10)' + ) + parser.add_argument( + '--validate-only', + action='store_true', + help='Only validate existing DICOM files without generating new ones' + ) + + parser.add_argument( + '--generate-missing', + action='store_true', + help='Generate DICOM files for all database records that are missing files' + ) + + parser.add_argument( + '--batch-size', + type=int, + default=100, + help='Number of files to process in each batch (default: 100)' + ) + parser.add_argument( + '--output-dir', + type=str, + help='Custom output directory for DICOM files' + ) + + def handle(self, *args, **options): + generator = DICOMGenerator() + + # Override output directory if specified + if options['output_dir']: + generator.dicom_storage_path = options['output_dir'] + os.makedirs(generator.dicom_storage_path, exist_ok=True) + self.stdout.write(f"Using custom output directory: {generator.dicom_storage_path}") + + try: + if options['create_test_data']: + self._create_test_data(generator, options) + elif options['validate_only']: + self._validate_existing_dicoms() + elif options['image_id']: + self._generate_single_image(generator, options['image_id']) + elif options['series_id']: + self._generate_series_images(generator, options['series_id']) + elif options['study_id']: + self._generate_study_images(generator, options['study_id']) + else: + self._generate_all_missing_dicoms(generator) + + except Exception as e: + raise CommandError(f'Error generating DICOM files: {str(e)}') + + def _create_test_data(self, generator, options): + """Create test study with DICOM files.""" + self.stdout.write("Creating test study with DICOM files...") + + # Get or create test tenant + tenant, created = Tenant.objects.get_or_create( + name='Test Hospital', + defaults={ + 'display_name': 'Test Hospital', + 'address_line1': '123 Test Street', + 'city': 'Test City', + 'state': 'Test State', + 'postal_code': '12345', + 'phone_number': '+1234567890', + 'email': 'test@hospital.com', + 'is_active': True + } + ) + if created: + self.stdout.write(f"Created test tenant: {tenant.name}") + + # Get or create test patient + patient, created = PatientProfile.objects.get_or_create( + mrn='TEST001', + tenant=tenant, + defaults={ + 'first_name': 'John', + 'last_name': 'Doe', + 'date_of_birth': '1980-01-01', + 'gender': 'MALE' + } + ) + if created: + self.stdout.write(f"Created test patient: {patient.first_name} {patient.last_name}") + + # Get or create test physician + physician, created = User.objects.get_or_create( + username='test_physician', + defaults={ + 'tenant': tenant, + 'first_name': 'Dr. Jane', + 'last_name': 'Smith', + 'email': 'physician@test.com' + } + ) + if created: + self.stdout.write(f"Created test physician: {physician.first_name} {physician.last_name}") + + # Create test study + study = ImagingStudy.objects.create( + tenant=tenant, + study_instance_uid=generate_uid(), + patient=patient, + referring_physician=physician, + modality=options['modality'], + study_description=f"Test {options['modality']} Study", + body_part='CHEST', + study_datetime=timezone.now(), + status='SCHEDULED', + priority='ROUTINE', + manufacturer='Test Manufacturer', + model_name='Test Model', + station_name='Test Station' + ) + + self.stdout.write(f"Created test study: {study.accession_number}") + + # Create test series with DICOM files + series = generator.create_test_dicom_series( + study=study, + modality=options['modality'], + num_images=options['num_images'] + ) + + self.stdout.write( + self.style.SUCCESS( + f"Successfully created test series with {options['num_images']} DICOM files" + ) + ) + self.stdout.write(f"Study ID: {study.study_id}") + self.stdout.write(f"Series ID: {series.series_id}") + self.stdout.write(f"Accession Number: {study.accession_number}") + + def _validate_existing_dicoms(self): + """Validate all existing DICOM files.""" + self.stdout.write("Validating existing DICOM files...") + + images = DICOMImage.objects.filter(file_path__isnull=False).exclude(file_path='') + total_images = images.count() + + if total_images == 0: + self.stdout.write("No DICOM images found with file paths.") + return + + valid_count = 0 + invalid_count = 0 + + for image in images: + if image.has_dicom_file(): + validation_result = DICOMValidator.validate_dicom_file(image.file_path) + if validation_result['valid']: + valid_count += 1 + self.stdout.write(f"✓ Valid: {image.sop_instance_uid}") + else: + invalid_count += 1 + self.stdout.write( + self.style.ERROR( + f"✗ Invalid: {image.sop_instance_uid} - {validation_result['errors']}" + ) + ) + else: + invalid_count += 1 + self.stdout.write( + self.style.WARNING(f"⚠ Missing file: {image.sop_instance_uid}") + ) + + self.stdout.write( + self.style.SUCCESS( + f"Validation complete: {valid_count} valid, {invalid_count} invalid/missing" + ) + ) + + def _generate_single_image(self, generator, image_id): + """Generate DICOM for a single image.""" + try: + image = DICOMImage.objects.get(image_id=image_id) + except DICOMImage.DoesNotExist: + raise CommandError(f'DICOMImage with ID {image_id} not found') + + self.stdout.write(f"Generating DICOM for image: {image.sop_instance_uid}") + + # Validate before generation + validation = image.validate_for_dicom_generation() + if not validation['valid']: + raise CommandError(f'Validation failed: {validation["errors"]}') + + if validation['warnings']: + for warning in validation['warnings']: + self.stdout.write(self.style.WARNING(f"Warning: {warning}")) + + # Generate DICOM file + file_path = image.generate_dicom_file() + + self.stdout.write( + self.style.SUCCESS(f"Successfully generated DICOM file: {file_path}") + ) + self.stdout.write(f"File size: {image.file_size_mb} MB") + + def _generate_series_images(self, generator, series_id): + """Generate DICOMs for all images in a series.""" + try: + series = ImagingSeries.objects.get(series_id=series_id) + except ImagingSeries.DoesNotExist: + raise CommandError(f'ImagingSeries with ID {series_id} not found') + + images = series.images.all() + if not images.exists(): + self.stdout.write(f"No images found in series {series.series_instance_uid}") + return + + self.stdout.write(f"Generating DICOMs for {images.count()} images in series...") + + generated_count = 0 + for image in images: + try: + validation = image.validate_for_dicom_generation() + if validation['valid']: + file_path = image.generate_dicom_file() + generated_count += 1 + self.stdout.write(f"✓ Generated: {image.sop_instance_uid}") + else: + self.stdout.write( + self.style.ERROR( + f"✗ Skipped {image.sop_instance_uid}: {validation['errors']}" + ) + ) + except Exception as e: + self.stdout.write( + self.style.ERROR(f"✗ Error generating {image.sop_instance_uid}: {str(e)}") + ) + + self.stdout.write( + self.style.SUCCESS(f"Generated {generated_count} DICOM files") + ) + + def _generate_study_images(self, generator, study_id): + """Generate DICOMs for all images in a study.""" + try: + study = ImagingStudy.objects.get(study_id=study_id) + except ImagingStudy.DoesNotExist: + raise CommandError(f'ImagingStudy with ID {study_id} not found') + + images = DICOMImage.objects.filter(series__study=study) + if not images.exists(): + self.stdout.write(f"No images found in study {study.accession_number}") + return + + self.stdout.write(f"Generating DICOMs for {images.count()} images in study...") + + generated_count = 0 + for image in images: + try: + validation = image.validate_for_dicom_generation() + if validation['valid']: + file_path = image.generate_dicom_file() + generated_count += 1 + self.stdout.write(f"✓ Generated: {image.sop_instance_uid}") + else: + self.stdout.write( + self.style.ERROR( + f"✗ Skipped {image.sop_instance_uid}: {validation['errors']}" + ) + ) + except Exception as e: + self.stdout.write( + self.style.ERROR(f"✗ Error generating {image.sop_instance_uid}: {str(e)}") + ) + + self.stdout.write( + self.style.SUCCESS(f"Generated {generated_count} DICOM files for study {study.accession_number}") + ) + + def _generate_all_missing_dicoms(self, generator): + """Generate DICOM files for all images that don't have files.""" + self.stdout.write("Generating DICOM files for all images without files...") + + # Find images without DICOM files + images_without_files = DICOMImage.objects.filter( + models.Q(file_path__isnull=True) | models.Q(file_path='') + ) + + total_images = images_without_files.count() + if total_images == 0: + self.stdout.write("All DICOM images already have files.") + return + + self.stdout.write(f"Found {total_images} images without DICOM files") + + generated_count = 0 + for image in images_without_files: + try: + validation = image.validate_for_dicom_generation() + if validation['valid']: + file_path = image.generate_dicom_file() + generated_count += 1 + if generated_count % 10 == 0: + self.stdout.write(f"Generated {generated_count}/{total_images} files...") + else: + self.stdout.write( + self.style.ERROR( + f"✗ Skipped {image.sop_instance_uid}: {validation['errors']}" + ) + ) + except Exception as e: + self.stdout.write( + self.style.ERROR(f"✗ Error generating {image.sop_instance_uid}: {str(e)}") + ) + + self.stdout.write( + self.style.SUCCESS(f"Generated {generated_count} DICOM files") + ) diff --git a/radiology/managers.py b/radiology/managers.py new file mode 100644 index 00000000..4e261bd7 --- /dev/null +++ b/radiology/managers.py @@ -0,0 +1,299 @@ +""" +Managers for radiology app models. +Provides optimized queries and business logic methods. +""" + +from django.db import models, transaction +from django.utils import timezone +from django.core.exceptions import ValidationError +from datetime import timedelta +from .constants import RadiologyBusinessRules + + +class ImagingStudyManager(models.Manager): + """Manager for ImagingStudy model with optimized queries.""" + + def with_related(self): + """Get studies with commonly accessed related objects.""" + return self.select_related( + 'patient', + 'referring_physician', + 'radiologist', + 'encounter', + 'imaging_order' + ).prefetch_related( + 'series__images', + 'report' + ) + + def by_status(self, status): + """Filter studies by status.""" + return self.filter(status=status) + + def by_priority(self, priority): + """Filter studies by priority.""" + return self.filter(priority=priority) + + def stat_studies(self): + """Get STAT and emergency studies.""" + return self.filter(priority__in=['STAT', 'EMERGENCY']) + + def pending_interpretation(self): + """Get studies pending radiologist interpretation.""" + return self.filter(status='COMPLETED', radiologist__isnull=True) + + def overdue_studies(self, hours=24): + """Get studies that are overdue for completion.""" + cutoff_time = timezone.now() - timedelta(hours=hours) + return self.filter( + study_datetime__lt=cutoff_time, + status__in=['SCHEDULED', 'ARRIVED', 'IN_PROGRESS'] + ) + + def by_modality(self, modality): + """Filter studies by modality.""" + return self.filter(modality=modality) + + def by_date_range(self, start_date, end_date): + """Filter studies by date range.""" + return self.filter(study_datetime__date__range=[start_date, end_date]) + + def for_patient(self, patient): + """Get all studies for a specific patient.""" + return self.filter(patient=patient).order_by('-study_datetime') + + def critical_pending(self): + """Get critical studies that need immediate attention.""" + return self.filter( + priority__in=['STAT', 'EMERGENCY'], + status__in=['SCHEDULED', 'ARRIVED', 'IN_PROGRESS'] + ).order_by('study_datetime') + + @transaction.atomic + def generate_accession_number(self, tenant): + """Generate unique accession number atomically.""" + from django.db import connection + + today = timezone.now().date() + date_str = today.strftime('%Y%m%d') + + # Use database-level sequence for thread safety + with connection.cursor() as cursor: + cursor.execute(""" + SELECT COALESCE(MAX(CAST(SUBSTRING(accession_number FROM 'RAD-\\d{8}-(\\d{4})') AS INTEGER)), 0) + 1 + FROM radiology_imaging_study + WHERE tenant_id = %s AND DATE(created_at) = %s + """, [tenant.id, today]) + + next_number = cursor.fetchone()[0] or 1 + + return f"RAD-{date_str}-{next_number:04d}" + + +class RadiologyReportManager(models.Manager): + """Manager for RadiologyReport model.""" + + def with_study_details(self): + """Get reports with study details.""" + return self.select_related( + 'study__patient', + 'study__referring_physician', + 'radiologist', + 'dictated_by', + 'transcribed_by' + ) + + def pending_reports(self): + """Get reports in draft or preliminary status.""" + return self.filter(status__in=['DRAFT', 'PRELIMINARY']) + + def critical_reports(self): + """Get reports with critical findings.""" + return self.filter(critical_finding=True) + + def uncommunicated_critical(self): + """Get critical reports that haven't been communicated.""" + return self.filter( + critical_finding=True, + critical_communicated=False + ) + + def by_radiologist(self, radiologist): + """Get reports by specific radiologist.""" + return self.filter(radiologist=radiologist) + + def overdue_reports(self): + """Get reports that are overdue based on study priority.""" + overdue_reports = [] + + for report in self.filter(status__in=['DRAFT', 'PRELIMINARY']): + study = report.study + priority = study.priority + + if priority in RadiologyBusinessRules.PRIORITY_ESCALATION_HOURS: + max_hours = RadiologyBusinessRules.PRIORITY_ESCALATION_HOURS[priority] + cutoff_time = study.study_datetime + timedelta(hours=max_hours) + + if timezone.now() > cutoff_time: + overdue_reports.append(report.id) + + return self.filter(id__in=overdue_reports) + + def finalized_today(self): + """Get reports finalized today.""" + today = timezone.now().date() + return self.filter( + status='FINAL', + finalized_datetime__date=today + ) + + +class ImagingOrderManager(models.Manager): + """Manager for ImagingOrder model.""" + + def with_patient_details(self): + """Get orders with patient details.""" + return self.select_related( + 'patient', + 'ordering_provider', + 'encounter' + ).prefetch_related('studies') + + def pending_orders(self): + """Get pending orders.""" + return self.filter(status='PENDING') + + def stat_orders(self): + """Get STAT and emergency orders.""" + return self.filter(priority__in=['STAT', 'EMERGENCY']) + + def by_modality(self, modality): + """Filter orders by modality.""" + return self.filter(modality=modality) + + def overdue_scheduling(self, hours=24): + """Get orders overdue for scheduling.""" + cutoff_time = timezone.now() - timedelta(hours=hours) + return self.filter( + order_datetime__lt=cutoff_time, + status='PENDING' + ) + + def for_scheduling(self): + """Get orders ready for scheduling.""" + return self.filter( + status__in=['PENDING', 'SCHEDULED'] + ).order_by('priority', 'order_datetime') + + @transaction.atomic + def generate_order_number(self, tenant): + """Generate unique order number atomically.""" + from django.db import connection + + with connection.cursor() as cursor: + cursor.execute(""" + SELECT COALESCE(MAX(CAST(SUBSTRING(order_number FROM 'IMG-\\d+-(\\d{6})') AS INTEGER)), 0) + 1 + FROM radiology_imaging_order + WHERE tenant_id = %s + """, [tenant.id]) + + next_number = cursor.fetchone()[0] or 1 + + return f"IMG-{tenant.id}-{next_number:06d}" + + +class ImagingSeriesManager(models.Manager): + """Manager for ImagingSeries model.""" + + def with_study_patient(self): + """Get series with study and patient details.""" + return self.select_related( + 'study__patient', + 'study__referring_physician' + ) + + def by_modality(self, modality): + """Filter series by modality.""" + return self.filter(modality=modality) + + def with_images(self): + """Get series with their images.""" + return self.prefetch_related('images') + + def incomplete_series(self): + """Get series that may be incomplete.""" + return self.filter( + number_of_instances=0 + ).exclude( + study__status='CANCELLED' + ) + + +class DICOMImageManager(models.Manager): + """Manager for DICOMImage model.""" + + def with_series_study(self): + """Get images with series and study details.""" + return self.select_related( + 'series__study__patient' + ) + + def unprocessed(self): + """Get unprocessed images.""" + return self.filter(processed=False) + + def by_quality(self, quality): + """Filter images by quality.""" + return self.filter(image_quality=quality) + + def poor_quality(self): + """Get images with poor or unacceptable quality.""" + return self.filter( + image_quality__in=['POOR', 'UNACCEPTABLE'] + ) + + def large_files(self, size_mb=100): + """Get images larger than specified size in MB.""" + size_bytes = size_mb * 1024 * 1024 + return self.filter(file_size__gt=size_bytes) + + def archived(self): + """Get archived images.""" + return self.filter(archived=True) + + +class ReportTemplateManager(models.Manager): + """Manager for ReportTemplate model.""" + + def active_templates(self): + """Get active templates.""" + return self.filter(is_active=True) + + def for_modality(self, modality): + """Get templates for specific modality.""" + return self.filter( + modality__in=[modality, 'ALL'], + is_active=True + ) + + def for_body_part(self, body_part): + """Get templates for specific body part.""" + return self.filter( + body_part__in=[body_part, 'ALL'], + is_active=True + ) + + def default_template(self, modality, body_part): + """Get default template for modality and body part.""" + return self.filter( + modality__in=[modality, 'ALL'], + body_part__in=[body_part, 'ALL'], + is_default=True, + is_active=True + ).first() + + def most_used(self, limit=10): + """Get most frequently used templates.""" + return self.filter( + is_active=True + ).order_by('-usage_count')[:limit] diff --git a/radiology/migrations/0001_initial.py b/radiology/migrations/0001_initial.py index 7883f2ae..bc22d9aa 100644 --- a/radiology/migrations/0001_initial.py +++ b/radiology/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-09-19 10:58 +# Generated by Django 5.2.6 on 2025-09-26 18:33 import django.db.models.deletion import django.utils.timezone diff --git a/radiology/migrations/0002_remove_imagingstudy_radiology_i_patient_b047df_idx_and_more.py b/radiology/migrations/0002_remove_imagingstudy_radiology_i_patient_b047df_idx_and_more.py new file mode 100644 index 00000000..9e388639 --- /dev/null +++ b/radiology/migrations/0002_remove_imagingstudy_radiology_i_patient_b047df_idx_and_more.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.6 on 2025-09-29 13:23 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0001_initial"), + ("emr", "0004_alter_encounter_status"), + ("patients", "0001_initial"), + ("radiology", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveIndex( + model_name="imagingstudy", + name="radiology_i_patient_b047df_idx", + ), + migrations.RemoveIndex( + model_name="imagingstudy", + name="radiology_i_modalit_f5025a_idx", + ), + migrations.RemoveField( + model_name="imagingstudy", + name="study_date", + ), + migrations.RemoveField( + model_name="imagingstudy", + name="study_time", + ), + migrations.AddIndex( + model_name="imagingstudy", + index=models.Index( + fields=["patient", "study_datetime"], + name="radiology_i_patient_a17d50_idx", + ), + ), + migrations.AddIndex( + model_name="imagingstudy", + index=models.Index( + fields=["modality", "study_datetime"], + name="radiology_i_modalit_2ac54f_idx", + ), + ), + ] diff --git a/radiology/migrations/0003_imagingstudy_arrived_datetime_and_more.py b/radiology/migrations/0003_imagingstudy_arrived_datetime_and_more.py new file mode 100644 index 00000000..6758dec8 --- /dev/null +++ b/radiology/migrations/0003_imagingstudy_arrived_datetime_and_more.py @@ -0,0 +1,69 @@ +# Generated by Django 5.2.6 on 2025-09-29 13:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "radiology", + "0002_remove_imagingstudy_radiology_i_patient_b047df_idx_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="imagingstudy", + name="arrived_datetime", + field=models.DateTimeField( + blank=True, + help_text="Date and time patient arrived for study", + null=True, + ), + ), + migrations.AddField( + model_name="imagingstudy", + name="completed_datetime", + field=models.DateTimeField( + blank=True, + help_text="Date and time study acquisition completed", + null=True, + ), + ), + migrations.AddField( + model_name="imagingstudy", + name="finalized_datetime", + field=models.DateTimeField( + blank=True, help_text="Date and time study was finalized", null=True + ), + ), + migrations.AddField( + model_name="imagingstudy", + name="interpreted_datetime", + field=models.DateTimeField( + blank=True, help_text="Date and time study was interpreted", null=True + ), + ), + migrations.AddField( + model_name="imagingstudy", + name="scheduled_datetime", + field=models.DateTimeField( + blank=True, help_text="Date and time study was scheduled", null=True + ), + ), + migrations.AddField( + model_name="imagingstudy", + name="started_datetime", + field=models.DateTimeField( + blank=True, + help_text="Date and time study acquisition started", + null=True, + ), + ), + migrations.AlterField( + model_name="imagingstudy", + name="study_datetime", + field=models.DateTimeField(help_text="Planned study date and time"), + ), + ] diff --git a/radiology/migrations/0004_remove_imagingseries_series_date_and_more.py b/radiology/migrations/0004_remove_imagingseries_series_date_and_more.py new file mode 100644 index 00000000..f6841539 --- /dev/null +++ b/radiology/migrations/0004_remove_imagingseries_series_date_and_more.py @@ -0,0 +1,117 @@ +# Generated by Django 5.2.6 on 2025-09-29 14:04 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("radiology", "0003_imagingstudy_arrived_datetime_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveField( + model_name="imagingseries", + name="series_date", + ), + migrations.RemoveField( + model_name="imagingseries", + name="series_time", + ), + migrations.AddField( + model_name="dicomimage", + name="archived_datetime", + field=models.DateTimeField( + blank=True, help_text="Date and time image was archived", null=True + ), + ), + migrations.AddField( + model_name="dicomimage", + name="processed_datetime", + field=models.DateTimeField( + blank=True, help_text="Date and time image was processed", null=True + ), + ), + migrations.AddField( + model_name="dicomimage", + name="quality_checked_datetime", + field=models.DateTimeField( + blank=True, + help_text="Date and time image quality was checked", + null=True, + ), + ), + migrations.AddField( + model_name="imagingorder", + name="approved_datetime", + field=models.DateTimeField( + blank=True, help_text="Date and time order was approved", null=True + ), + ), + migrations.AddField( + model_name="imagingorder", + name="cancelled_datetime", + field=models.DateTimeField( + blank=True, help_text="Date and time order was cancelled", null=True + ), + ), + migrations.AddField( + model_name="imagingorder", + name="completed_datetime", + field=models.DateTimeField( + blank=True, help_text="Date and time order was completed", null=True + ), + ), + migrations.AddField( + model_name="imagingseries", + name="archived_datetime", + field=models.DateTimeField( + blank=True, help_text="Date and time series was archived", null=True + ), + ), + migrations.AddField( + model_name="imagingseries", + name="completed_datetime", + field=models.DateTimeField( + blank=True, + help_text="Date and time series acquisition completed", + null=True, + ), + ), + migrations.AddField( + model_name="imagingseries", + name="processed_datetime", + field=models.DateTimeField( + blank=True, help_text="Date and time series was processed", null=True + ), + ), + migrations.AddField( + model_name="imagingseries", + name="started_datetime", + field=models.DateTimeField( + blank=True, + help_text="Date and time series acquisition started", + null=True, + ), + ), + migrations.AlterField( + model_name="imagingseries", + name="series_datetime", + field=models.DateTimeField(help_text="Series acquisition date and time"), + ), + migrations.AlterField( + model_name="radiologyreport", + name="critical_communicated_to", + field=models.ForeignKey( + blank=True, + help_text="Person critical findings were communicated to", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="critical_communicated_reports", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/radiology/migrations/__pycache__/0001_initial.cpython-312.pyc b/radiology/migrations/__pycache__/0001_initial.cpython-312.pyc index d15be131..267b11b1 100644 Binary files a/radiology/migrations/__pycache__/0001_initial.cpython-312.pyc and b/radiology/migrations/__pycache__/0001_initial.cpython-312.pyc differ diff --git a/radiology/migrations/__pycache__/0002_remove_imagingstudy_radiology_i_patient_b047df_idx_and_more.cpython-312.pyc b/radiology/migrations/__pycache__/0002_remove_imagingstudy_radiology_i_patient_b047df_idx_and_more.cpython-312.pyc new file mode 100644 index 00000000..35e5a7eb Binary files /dev/null and b/radiology/migrations/__pycache__/0002_remove_imagingstudy_radiology_i_patient_b047df_idx_and_more.cpython-312.pyc differ diff --git a/radiology/migrations/__pycache__/0003_imagingstudy_arrived_datetime_and_more.cpython-312.pyc b/radiology/migrations/__pycache__/0003_imagingstudy_arrived_datetime_and_more.cpython-312.pyc new file mode 100644 index 00000000..c283025c Binary files /dev/null and b/radiology/migrations/__pycache__/0003_imagingstudy_arrived_datetime_and_more.cpython-312.pyc differ diff --git a/radiology/migrations/__pycache__/0004_remove_imagingseries_series_date_and_more.cpython-312.pyc b/radiology/migrations/__pycache__/0004_remove_imagingseries_series_date_and_more.cpython-312.pyc new file mode 100644 index 00000000..669c1a39 Binary files /dev/null and b/radiology/migrations/__pycache__/0004_remove_imagingseries_series_date_and_more.cpython-312.pyc differ diff --git a/radiology/models.py b/radiology/models.py index 23690814..42127fc1 100644 --- a/radiology/models.py +++ b/radiology/models.py @@ -5,72 +5,26 @@ Provides imaging orders, DICOM management, and radiology workflows. import uuid from django.db import models -from django.core.validators import RegexValidator, MinValueValidator, MaxValueValidator +from django.core.validators import MinValueValidator, MaxValueValidator +from django.core.exceptions import ValidationError from django.utils import timezone from django.conf import settings from datetime import timedelta, datetime, date from decimal import Decimal import json +from .constants import RadiologyChoices, RadiologyValidators, RadiologyBusinessRules +from .managers import ( + ImagingStudyManager, RadiologyReportManager, ImagingOrderManager, + ImagingSeriesManager, DICOMImageManager, ReportTemplateManager +) + class ImagingStudy(models.Model): """ Imaging study model for radiology studies and DICOM management. + Improved with centralized constants, validation, and optimized structure. """ - MODALITY_CHOICES=[ - ('CR', 'Computed Radiography'), - ('CT', 'Computed Tomography'), - ('MR', 'Magnetic Resonance'), - ('US', 'Ultrasound'), - ('XA', 'X-Ray Angiography'), - ('RF', 'Radiofluoroscopy'), - ('DX', 'Digital Radiography'), - ('MG', 'Mammography'), - ('NM', 'Nuclear Medicine'), - ('PT', 'Positron Emission Tomography'), - ('OT', 'Other'), - ] - BODY_PART_CHOICES=[ - ('HEAD', 'Head'), - ('NECK', 'Neck'), - ('CHEST', 'Chest'), - ('ABDOMEN', 'Abdomen'), - ('PELVIS', 'Pelvis'), - ('SPINE', 'Spine'), - ('EXTREMITY', 'Extremity'), - ('BREAST', 'Breast'), - ('HEART', 'Heart'), - ('BRAIN', 'Brain'), - ('WHOLE_BODY', 'Whole Body'), - ('OTHER', 'Other'), - ] - STATUS_CHOICES=[ - ('SCHEDULED', 'Scheduled'), - ('ARRIVED', 'Arrived'), - ('IN_PROGRESS', 'In Progress'), - ('COMPLETED', 'Completed'), - ('INTERPRETED', 'Interpreted'), - ('FINALIZED', 'Finalized'), - ('CANCELLED', 'Cancelled'), - ] - PRIORITY_CHOICES = [ - ('ROUTINE', 'Routine'), - ('URGENT', 'Urgent'), - ('STAT', 'STAT'), - ('EMERGENCY', 'Emergency'), - ] - IMAGE_QUALITY_CHOICES = [ - ('EXCELLENT', 'Excellent'), - ('GOOD', 'Good'), - ('ADEQUATE', 'Adequate'), - ('POOR', 'Poor'), - ('UNACCEPTABLE', 'Unacceptable'), - ] - COMPLETION_STATUS_CHOICES = [ - ('COMPLETE', 'Complete'), - ('PARTIAL', 'Partial'), - ('INCOMPLETE', 'Incomplete'), - ] # Tenant relationship tenant = models.ForeignKey( 'core.Tenant', @@ -122,7 +76,7 @@ class ImagingStudy(models.Model): # Study Details modality = models.CharField( max_length=10, - choices=MODALITY_CHOICES, + choices=RadiologyChoices.Modality.choices, help_text='Study modality' ) study_description = models.CharField( @@ -131,19 +85,43 @@ class ImagingStudy(models.Model): ) body_part = models.CharField( max_length=100, - choices=BODY_PART_CHOICES, + choices=RadiologyChoices.BodyPart.choices, help_text='Body part examined' ) - # Study Dates and Times - study_date = models.DateField( - help_text='Study date' - ) - study_time = models.TimeField( - help_text='Study time' + # Study Scheduling and Workflow Timestamps + scheduled_datetime = models.DateTimeField( + blank=True, + null=True, + help_text='Date and time study was scheduled' ) study_datetime = models.DateTimeField( - help_text='Study date and time' + help_text='Planned study date and time' + ) + arrived_datetime = models.DateTimeField( + blank=True, + null=True, + help_text='Date and time patient arrived for study' + ) + started_datetime = models.DateTimeField( + blank=True, + null=True, + help_text='Date and time study acquisition started' + ) + completed_datetime = models.DateTimeField( + blank=True, + null=True, + help_text='Date and time study acquisition completed' + ) + interpreted_datetime = models.DateTimeField( + blank=True, + null=True, + help_text='Date and time study was interpreted' + ) + finalized_datetime = models.DateTimeField( + blank=True, + null=True, + help_text='Date and time study was finalized' ) # Clinical Information @@ -167,7 +145,7 @@ class ImagingStudy(models.Model): # Study Status status = models.CharField( max_length=20, - choices=STATUS_CHOICES, + choices=RadiologyChoices.StudyStatus.choices, default='SCHEDULED', help_text='Study status' ) @@ -175,7 +153,7 @@ class ImagingStudy(models.Model): # Priority priority = models.CharField( max_length=20, - choices=PRIORITY_CHOICES, + choices=RadiologyChoices.Priority.choices, default='ROUTINE', help_text='Study priority' ) @@ -239,14 +217,14 @@ class ImagingStudy(models.Model): # Quality and Completion image_quality = models.CharField( max_length=20, - choices=IMAGE_QUALITY_CHOICES, + choices=RadiologyChoices.ImageQuality.choices, blank=True, null=True, help_text='Image quality assessment' ) completion_status = models.CharField( max_length=20, - choices=COMPLETION_STATUS_CHOICES, + choices=RadiologyChoices.CompletionStatus.choices, default='COMPLETE', help_text='Study completion status' ) @@ -299,6 +277,9 @@ class ImagingStudy(models.Model): help_text='User who created the study' ) + # Custom manager + objects = ImagingStudyManager() + class Meta: db_table = 'radiology_imaging_study' verbose_name = 'Imaging Study' @@ -306,8 +287,8 @@ class ImagingStudy(models.Model): ordering = ['-study_datetime'] indexes = [ models.Index(fields=['tenant', 'status']), - models.Index(fields=['patient', 'study_date']), - models.Index(fields=['modality', 'study_date']), + models.Index(fields=['patient', 'study_datetime']), + models.Index(fields=['modality', 'study_datetime']), models.Index(fields=['accession_number']), models.Index(fields=['study_instance_uid']), models.Index(fields=['priority']), @@ -336,10 +317,10 @@ class ImagingStudy(models.Model): self.accession_number = f"RAD-{today.strftime('%Y%m%d')}-0001" # Set study_datetime from date and time - if self.study_date and self.study_time: - self.study_datetime = timezone.make_aware( - datetime.combine(self.study_date, self.study_time) - ) + # if self.study_datetime: + # self.study_datetime = timezone.make_aware( + # datetime.combine(self.study_date, self.study_time) + # ) super().save(*args, **kwargs) @@ -356,6 +337,131 @@ class ImagingStudy(models.Model): Check if study is complete. """ return self.status in ['COMPLETED', 'INTERPRETED', 'FINALIZED'] + + @property + def is_final(self): + """ + Check if study is finalized. + """ + return self.status == 'FINALIZED' + + @property + def acquisition_duration(self): + """ + Calculate acquisition duration in minutes. + """ + if self.started_datetime and self.completed_datetime: + delta = self.completed_datetime - self.started_datetime + return int(delta.total_seconds() / 60) + return None + + @property + def total_turnaround_time(self): + """ + Calculate total turnaround time from scheduled to finalized in minutes. + """ + if self.scheduled_datetime and self.finalized_datetime: + delta = self.finalized_datetime - self.scheduled_datetime + return int(delta.total_seconds() / 60) + return None + + @property + def interpretation_turnaround_time(self): + """ + Calculate interpretation turnaround time from completed to interpreted in minutes. + """ + if self.completed_datetime and self.interpreted_datetime: + delta = self.interpreted_datetime - self.completed_datetime + return int(delta.total_seconds() / 60) + return None + + @property + def patient_wait_time(self): + """ + Calculate patient wait time from arrival to study start in minutes. + """ + if self.arrived_datetime and self.started_datetime: + delta = self.started_datetime - self.arrived_datetime + return int(delta.total_seconds() / 60) + return None + + @property + def is_overdue(self): + """ + Check if study is overdue based on priority and business rules. + """ + if not self.scheduled_datetime or self.is_complete: + return False + + now = timezone.now() + hours_since_scheduled = (now - self.scheduled_datetime).total_seconds() / 3600 + + # Use business rules from constants + max_hours = RadiologyBusinessRules.PRIORITY_ESCALATION_HOURS.get(self.priority, 24) + return hours_since_scheduled > max_hours + + @property + def workflow_stage(self): + """ + Get current workflow stage with timestamp. + """ + stages = [ + ('scheduled', self.scheduled_datetime), + ('arrived', self.arrived_datetime), + ('started', self.started_datetime), + ('completed', self.completed_datetime), + ('interpreted', self.interpreted_datetime), + ('finalized', self.finalized_datetime), + ] + + current_stage = 'scheduled' + for stage, timestamp in stages: + if timestamp: + current_stage = stage + else: + break + + return current_stage + + def clean(self): + """ + Validate study data and business rules. + """ + super().clean() + + # Validate timestamp sequence + timestamps = [ + ('scheduled_datetime', self.scheduled_datetime), + ('arrived_datetime', self.arrived_datetime), + ('started_datetime', self.started_datetime), + ('completed_datetime', self.completed_datetime), + ('interpreted_datetime', self.interpreted_datetime), + ('finalized_datetime', self.finalized_datetime), + ] + + previous_timestamp = None + for field_name, timestamp in timestamps: + if timestamp: + if previous_timestamp and timestamp < previous_timestamp: + raise ValidationError(f'{field_name} cannot be before previous workflow step') + previous_timestamp = timestamp + + # Validate status transitions + if self.pk: # Only for existing objects + old_instance = ImagingStudy.objects.get(pk=self.pk) + valid_transitions = RadiologyBusinessRules.VALID_STATUS_TRANSITIONS.get(old_instance.status, []) + if self.status != old_instance.status and self.status not in valid_transitions: + raise ValidationError(f'Invalid status transition from {old_instance.status} to {self.status}') + + # Validate required fields based on status + if self.status == 'COMPLETED' and not self.completed_datetime: + self.completed_datetime = timezone.now() + + if self.status == 'INTERPRETED' and not self.interpreted_datetime: + self.interpreted_datetime = timezone.now() + + if self.status == 'FINALIZED' and not self.finalized_datetime: + self.finalized_datetime = timezone.now() class ImagingSeries(models.Model): @@ -390,19 +496,7 @@ class ImagingSeries(models.Model): # Series Details modality = models.CharField( max_length=10, - choices=[ - ('CR', 'Computed Radiography'), - ('CT', 'Computed Tomography'), - ('MR', 'Magnetic Resonance'), - ('US', 'Ultrasound'), - ('XA', 'X-Ray Angiography'), - ('RF', 'Radiofluoroscopy'), - ('DX', 'Digital Radiography'), - ('MG', 'Mammography'), - ('NM', 'Nuclear Medicine'), - ('PT', 'Positron Emission Tomography'), - ('OT', 'Other'), - ], + choices=RadiologyChoices.Modality.choices, help_text='Series modality' ) series_description = models.CharField( @@ -418,15 +512,29 @@ class ImagingSeries(models.Model): help_text='Protocol name' ) - # Series Dates and Times - series_date = models.DateField( - help_text='Series date' - ) - series_time = models.TimeField( - help_text='Series time' - ) + # Series Workflow Timestamps series_datetime = models.DateTimeField( - help_text='Series date and time' + help_text='Series acquisition date and time' + ) + started_datetime = models.DateTimeField( + blank=True, + null=True, + help_text='Date and time series acquisition started' + ) + completed_datetime = models.DateTimeField( + blank=True, + null=True, + help_text='Date and time series acquisition completed' + ) + processed_datetime = models.DateTimeField( + blank=True, + null=True, + help_text='Date and time series was processed' + ) + archived_datetime = models.DateTimeField( + blank=True, + null=True, + help_text='Date and time series was archived' ) # Technical Parameters @@ -528,6 +636,9 @@ class ImagingSeries(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + # Custom manager + objects = ImagingSeriesManager() + class Meta: db_table = 'radiology_imaging_series' verbose_name = 'Imaging Series' @@ -546,12 +657,10 @@ class ImagingSeries(models.Model): def save(self, *args, **kwargs): """ - Set series_datetime from date and time. + Set series_datetime if not provided. """ - if self.series_date and self.series_time: - self.series_datetime = timezone.make_aware( - datetime.combine(self.series_date, self.series_time) - ) + if not self.series_datetime: + self.series_datetime = timezone.now() super().save(*args, **kwargs) @@ -681,16 +790,27 @@ class DICOMImage(models.Model): help_text='Acquisition date and time' ) + # Processing Workflow Timestamps + processed_datetime = models.DateTimeField( + blank=True, + null=True, + help_text='Date and time image was processed' + ) + quality_checked_datetime = models.DateTimeField( + blank=True, + null=True, + help_text='Date and time image quality was checked' + ) + archived_datetime = models.DateTimeField( + blank=True, + null=True, + help_text='Date and time image was archived' + ) + # Quality and Status image_quality = models.CharField( max_length=20, - choices=[ - ('EXCELLENT', 'Excellent'), - ('GOOD', 'Good'), - ('ADEQUATE', 'Adequate'), - ('POOR', 'Poor'), - ('UNACCEPTABLE', 'Unacceptable'), - ], + choices=RadiologyChoices.ImageQuality.choices, blank=True, null=True, help_text='Image quality assessment' @@ -710,6 +830,9 @@ class DICOMImage(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + # Custom manager + objects = DICOMImageManager() + class Meta: db_table = 'radiology_dicom_image' verbose_name = 'DICOM Image' @@ -747,6 +870,53 @@ class DICOMImage(models.Model): Get file size in MB. """ return round(self.file_size / (1024 * 1024), 2) + + def generate_dicom_file(self, pixel_data=None): + """ + Generate DICOM file from this model instance. + + Args: + pixel_data: Optional numpy array for pixel data + + Returns: + str: Path to generated DICOM file + """ + from .services import DICOMGenerator + generator = DICOMGenerator() + return generator.generate_dicom_from_model(self, pixel_data) + + def validate_for_dicom_generation(self): + """ + Validate this instance for DICOM generation. + + Returns: + dict: Validation results + """ + from .services import DICOMValidator + return DICOMValidator.validate_dicom_image_model(self) + + def has_dicom_file(self): + """ + Check if DICOM file exists on disk. + + Returns: + bool: True if file exists + """ + import os + return bool(self.file_path and os.path.exists(self.file_path)) + + def get_dicom_metadata(self): + """ + Extract metadata from DICOM file if it exists. + + Returns: + dict: DICOM metadata or None if file doesn't exist + """ + if not self.has_dicom_file(): + return None + + from .services import DICOMValidator + return DICOMValidator.validate_dicom_file(self.file_path) class RadiologyReport(models.Model): @@ -840,8 +1010,10 @@ class RadiologyReport(models.Model): default=False, help_text='Critical findings have been communicated' ) - critical_communicated_to = models.CharField( - max_length=100, + critical_communicated_to = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + related_name='critical_communicated_reports', blank=True, null=True, help_text='Person critical findings were communicated to' @@ -915,6 +1087,9 @@ class RadiologyReport(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + # Custom manager + objects = RadiologyReportManager() + class Meta: db_table = 'radiology_radiology_report' verbose_name = 'Radiology Report' @@ -1097,6 +1272,9 @@ class ReportTemplate(models.Model): help_text='User who created the template' ) + # Custom manager + objects = ReportTemplateManager() + class Meta: db_table = 'radiology_report_template' verbose_name = 'Report Template' @@ -1278,17 +1456,32 @@ class ImagingOrder(models.Model): help_text='Contrast administration route' ) - # Scheduling Information + # Order Workflow Timestamps requested_datetime = models.DateTimeField( blank=True, null=True, help_text='Requested study date and time' ) + approved_datetime = models.DateTimeField( + blank=True, + null=True, + help_text='Date and time order was approved' + ) scheduled_datetime = models.DateTimeField( blank=True, null=True, help_text='Scheduled study date and time' ) + cancelled_datetime = models.DateTimeField( + blank=True, + null=True, + help_text='Date and time order was cancelled' + ) + completed_datetime = models.DateTimeField( + blank=True, + null=True, + help_text='Date and time order was completed' + ) # Status status = models.CharField( @@ -1324,6 +1517,9 @@ class ImagingOrder(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + # Custom manager + objects = ImagingOrderManager() + class Meta: db_table = 'radiology_imaging_order' verbose_name = 'Imaging Order' @@ -1363,4 +1559,3 @@ class ImagingOrder(models.Model): Check if order is STAT priority. """ return self.priority in ['STAT', 'EMERGENCY'] - diff --git a/radiology/services.py b/radiology/services.py new file mode 100644 index 00000000..64c4ccf6 --- /dev/null +++ b/radiology/services.py @@ -0,0 +1,624 @@ +""" +Radiology services for DICOM generation and management. +""" + +import os +import numpy as np +from datetime import datetime, date +from typing import Optional, Dict, Any, Tuple +from django.conf import settings +from django.utils import timezone +from pydicom import Dataset, FileMetaDataset +from pydicom.uid import ( + ExplicitVRLittleEndian, ImplicitVRLittleEndian, + CTImageStorage, MRImageStorage, ComputedRadiographyImageStorage, + DigitalXRayImageStorageForPresentation, UltrasoundImageStorage, + generate_uid +) + +from .models import DICOMImage, ImagingSeries, ImagingStudy + + +class DICOMGenerator: + """ + Service class for generating DICOM files from DICOMImage model data. + """ + + # DICOM SOP Class UIDs for different modalities + SOP_CLASS_UIDS = { + 'CT': CTImageStorage, + 'MR': MRImageStorage, + 'CR': ComputedRadiographyImageStorage, + 'DX': DigitalXRayImageStorageForPresentation, + 'US': UltrasoundImageStorage, + 'XA': '1.2.840.10008.5.1.4.1.1.12.1', # X-Ray Angiography + 'RF': '1.2.840.10008.5.1.4.1.1.12.2', # Radiofluoroscopy + 'MG': '1.2.840.10008.5.1.4.1.1.1.2', # Mammography + 'NM': '1.2.840.10008.5.1.4.1.1.20', # Nuclear Medicine + 'PT': '1.2.840.10008.5.1.4.1.1.128', # PET + } + + def __init__(self): + """Initialize DICOM generator.""" + self.media_root = getattr(settings, 'MEDIA_ROOT', 'media') + self.dicom_storage_path = os.path.join(self.media_root, 'dicom') + + # Ensure DICOM storage directory exists + os.makedirs(self.dicom_storage_path, exist_ok=True) + + def generate_dicom_from_model(self, dicom_image: DICOMImage, pixel_data: Optional[np.ndarray] = None) -> str: + """ + Generate a DICOM file from a DICOMImage model instance. + + Args: + dicom_image: DICOMImage model instance + pixel_data: Optional numpy array for pixel data. If None, generates synthetic data. + + Returns: + str: Path to the generated DICOM file + """ + # Create DICOM dataset + ds = Dataset() + ds.file_meta = FileMetaDataset() + + # Set transfer syntax + ds.file_meta.TransferSyntaxUID = ExplicitVRLittleEndian + ds.file_meta.MediaStorageSOPClassUID = dicom_image.sop_class_uid or self._get_sop_class_uid(dicom_image.series.modality) + ds.file_meta.MediaStorageSOPInstanceUID = dicom_image.sop_instance_uid + ds.file_meta.ImplementationClassUID = generate_uid() + ds.file_meta.ImplementationVersionName = "HospitalMgmt_v4.0" + + # Patient Information + patient = dicom_image.patient + ds.PatientName = f"{patient.last_name}^{patient.first_name}" + ds.PatientID = str(patient.patient_id) + ds.PatientBirthDate = patient.date_of_birth.strftime('%Y%m%d') if patient.date_of_birth else '' + ds.PatientSex = patient.gender if hasattr(patient, 'gender') else 'O' + + # Study Information + study = dicom_image.study + ds.StudyInstanceUID = study.study_instance_uid + ds.StudyDate = study.study_datetime.strftime('%Y%m%d') + ds.StudyTime = study.study_datetime.strftime('%H%M%S.%f')[:-3] + ds.StudyDescription = study.study_description + ds.AccessionNumber = study.accession_number + ds.ReferringPhysicianName = f"{study.referring_physician.last_name}^{study.referring_physician.first_name}" + + # Series Information + series = dicom_image.series + ds.SeriesInstanceUID = series.series_instance_uid + ds.SeriesNumber = str(series.series_number) + ds.SeriesDescription = series.series_description or '' + ds.SeriesDate = series.series_datetime.strftime('%Y%m%d') + ds.SeriesTime = series.series_datetime.strftime('%H%M%S.%f')[:-3] + ds.Modality = series.modality + ds.ProtocolName = series.protocol_name or '' + + # Instance Information + ds.SOPClassUID = dicom_image.sop_class_uid or self._get_sop_class_uid(series.modality) + ds.SOPInstanceUID = dicom_image.sop_instance_uid + ds.InstanceNumber = str(dicom_image.instance_number) + + # Image Information + if dicom_image.content_date: + ds.ContentDate = dicom_image.content_date.strftime('%Y%m%d') + if dicom_image.content_time: + ds.ContentTime = dicom_image.content_time.strftime('%H%M%S.%f')[:-3] + if dicom_image.acquisition_datetime: + ds.AcquisitionDate = dicom_image.acquisition_datetime.strftime('%Y%m%d') + ds.AcquisitionTime = dicom_image.acquisition_datetime.strftime('%H%M%S.%f')[:-3] + + # Image Type + if dicom_image.image_type: + ds.ImageType = dicom_image.image_type.split('\\') + else: + ds.ImageType = ['ORIGINAL', 'PRIMARY'] + + # Equipment Information + if study.manufacturer: + ds.Manufacturer = study.manufacturer + if study.model_name: + ds.ManufacturerModelName = study.model_name + if study.station_name: + ds.StationName = study.station_name + + # Image Dimensions and Pixel Data + ds.Rows = dicom_image.rows + ds.Columns = dicom_image.columns + ds.BitsAllocated = dicom_image.bits_allocated + ds.BitsStored = dicom_image.bits_stored + ds.HighBit = dicom_image.bits_stored - 1 + ds.PixelRepresentation = 0 # Unsigned by default + ds.SamplesPerPixel = 1 + ds.PhotometricInterpretation = "MONOCHROME2" + + # Image Position and Orientation + if dicom_image.image_position: + ds.ImagePositionPatient = [float(x) for x in dicom_image.image_position.split('\\')] + if dicom_image.image_orientation: + ds.ImageOrientationPatient = [float(x) for x in dicom_image.image_orientation.split('\\')] + if dicom_image.slice_location is not None: + ds.SliceLocation = str(dicom_image.slice_location) + + # Pixel Spacing + if series.pixel_spacing: + ds.PixelSpacing = [float(x) for x in series.pixel_spacing.split('\\')] + if series.slice_thickness: + ds.SliceThickness = str(series.slice_thickness) + if series.spacing_between_slices: + ds.SpacingBetweenSlices = str(series.spacing_between_slices) + + # Window/Level Settings + if dicom_image.window_center is not None: + ds.WindowCenter = str(dicom_image.window_center) + if dicom_image.window_width is not None: + ds.WindowWidth = str(dicom_image.window_width) + + # Patient Position + if series.patient_position: + ds.PatientPosition = series.patient_position + + # Technical Parameters + if study.kvp: + ds.KVP = str(study.kvp) + if study.exposure_time: + ds.ExposureTime = str(study.exposure_time) + + # Contrast Information + if series.contrast_agent: + ds.ContrastBolusAgent = series.contrast_agent + if series.contrast_route: + ds.ContrastBolusRoute = series.contrast_route + + # Generate or use provided pixel data + if pixel_data is None: + pixel_data = self._generate_synthetic_pixel_data( + dicom_image.rows, + dicom_image.columns, + dicom_image.bits_allocated + ) + + # Set pixel data + ds.PixelData = pixel_data.tobytes() + if dicom_image.bits_allocated <= 8: + ds["PixelData"].VR = "OB" + else: + ds["PixelData"].VR = "OW" + + # Transfer Syntax + if dicom_image.transfer_syntax_uid: + ds.file_meta.TransferSyntaxUID = dicom_image.transfer_syntax_uid + + # Generate file path + file_path = self._generate_file_path(dicom_image) + + # Save DICOM file + ds.save_as(file_path, enforce_file_format=True) + + # Update model with file information + dicom_image.file_path = file_path + dicom_image.file_size = os.path.getsize(file_path) + dicom_image.save(update_fields=['file_path', 'file_size']) + + return file_path + + def create_dicom_from_data(self, + study_data: Dict[str, Any], + series_data: Dict[str, Any], + image_data: Dict[str, Any], + pixel_data: Optional[np.ndarray] = None) -> str: + """ + Create a DICOM file from raw data dictionaries. + + Args: + study_data: Study-level DICOM data + series_data: Series-level DICOM data + image_data: Image-level DICOM data + pixel_data: Optional numpy array for pixel data + + Returns: + str: Path to the generated DICOM file + """ + ds = Dataset() + ds.file_meta = FileMetaDataset() + + # File Meta Information + ds.file_meta.TransferSyntaxUID = ExplicitVRLittleEndian + ds.file_meta.MediaStorageSOPClassUID = image_data.get('sop_class_uid', CTImageStorage) + ds.file_meta.MediaStorageSOPInstanceUID = image_data.get('sop_instance_uid', generate_uid()) + ds.file_meta.ImplementationClassUID = generate_uid() + ds.file_meta.ImplementationVersionName = "HospitalMgmt_v4.0" + + # Patient Information + ds.PatientName = study_data.get('patient_name', 'Anonymous^Patient') + ds.PatientID = study_data.get('patient_id', 'UNKNOWN') + ds.PatientBirthDate = study_data.get('patient_birth_date', '') + ds.PatientSex = study_data.get('patient_sex', 'O') + + # Study Information + ds.StudyInstanceUID = study_data.get('study_instance_uid', generate_uid()) + ds.StudyDate = study_data.get('study_date', datetime.now().strftime('%Y%m%d')) + ds.StudyTime = study_data.get('study_time', datetime.now().strftime('%H%M%S')) + ds.StudyDescription = study_data.get('study_description', '') + ds.AccessionNumber = study_data.get('accession_number', '') + + # Series Information + ds.SeriesInstanceUID = series_data.get('series_instance_uid', generate_uid()) + ds.SeriesNumber = str(series_data.get('series_number', 1)) + ds.SeriesDescription = series_data.get('series_description', '') + ds.Modality = series_data.get('modality', 'CT') + + # Instance Information + ds.SOPClassUID = image_data.get('sop_class_uid', self._get_sop_class_uid(ds.Modality)) + ds.SOPInstanceUID = image_data.get('sop_instance_uid', generate_uid()) + ds.InstanceNumber = str(image_data.get('instance_number', 1)) + + # Image Dimensions + rows = image_data.get('rows', 512) + columns = image_data.get('columns', 512) + bits_allocated = image_data.get('bits_allocated', 16) + + ds.Rows = rows + ds.Columns = columns + ds.BitsAllocated = bits_allocated + ds.BitsStored = image_data.get('bits_stored', bits_allocated) + ds.HighBit = ds.BitsStored - 1 + ds.PixelRepresentation = 0 # Unsigned + ds.SamplesPerPixel = 1 + ds.PhotometricInterpretation = "MONOCHROME2" + + # Generate pixel data if not provided + if pixel_data is None: + pixel_data = self._generate_synthetic_pixel_data(rows, columns, bits_allocated) + + # Set pixel data + ds.PixelData = pixel_data.tobytes() + if bits_allocated <= 8: + ds["PixelData"].VR = "OB" + else: + ds["PixelData"].VR = "OW" + + # Optional fields + if 'image_position' in image_data: + ds.ImagePositionPatient = image_data['image_position'] + if 'image_orientation' in image_data: + ds.ImageOrientationPatient = image_data['image_orientation'] + if 'slice_location' in image_data: + ds.SliceLocation = str(image_data['slice_location']) + if 'window_center' in image_data: + ds.WindowCenter = str(image_data['window_center']) + if 'window_width' in image_data: + ds.WindowWidth = str(image_data['window_width']) + + # Generate file path + filename = f"{ds.SOPInstanceUID}.dcm" + file_path = os.path.join(self.dicom_storage_path, filename) + + # Save DICOM file + ds.save_as(file_path, enforce_file_format=True) + + return file_path + + def generate_series_dicoms(self, series: ImagingSeries, num_images: int = 10) -> list: + """ + Generate multiple DICOM images for a series. + + Args: + series: ImagingSeries model instance + num_images: Number of images to generate + + Returns: + list: List of file paths for generated DICOM files + """ + file_paths = [] + + for i in range(num_images): + # Create DICOMImage instance + dicom_image = DICOMImage( + series=series, + sop_instance_uid=generate_uid(), + instance_number=i + 1, + sop_class_uid=self._get_sop_class_uid(series.modality), + rows=512, + columns=512, + bits_allocated=16, + bits_stored=16, + file_path='', # Will be set by generate_dicom_from_model + file_size=0, # Will be set by generate_dicom_from_model + ) + + # Add slice-specific data + if series.slice_thickness: + dicom_image.slice_location = i * series.slice_thickness + + # Set image position for axial slices + if series.modality in ['CT', 'MR']: + dicom_image.image_position = f"0\\0\\{i * (series.slice_thickness or 5)}" + dicom_image.image_orientation = "1\\0\\0\\0\\1\\0" + + # Save the model instance + dicom_image.save() + + # Generate DICOM file + file_path = self.generate_dicom_from_model(dicom_image) + file_paths.append(file_path) + + # Update series metrics + series.number_of_instances = num_images + series.save(update_fields=['number_of_instances']) + + return file_paths + + def _get_sop_class_uid(self, modality: str) -> str: + """Get appropriate SOP Class UID for modality.""" + return self.SOP_CLASS_UIDS.get(modality, CTImageStorage) + + def _generate_file_path(self, dicom_image: DICOMImage) -> str: + """Generate file path for DICOM image.""" + study = dicom_image.study + series = dicom_image.series + + # Create directory structure: tenant/patient/study/series/ + path_parts = [ + self.dicom_storage_path, + str(study.tenant.id), + str(study.patient.patient_id), + study.accession_number, + f"series_{series.series_number:03d}" + ] + + directory = os.path.join(*path_parts) + os.makedirs(directory, exist_ok=True) + + # Generate filename + filename = f"image_{dicom_image.instance_number:04d}_{dicom_image.sop_instance_uid}.dcm" + + return os.path.join(directory, filename) + + def _generate_synthetic_pixel_data(self, rows: int, columns: int, bits_allocated: int) -> np.ndarray: + """ + Generate synthetic pixel data for testing purposes. + + Args: + rows: Number of rows + columns: Number of columns + bits_allocated: Bits allocated per pixel + + Returns: + np.ndarray: Synthetic pixel data + """ + if bits_allocated <= 8: + dtype = np.uint8 + max_value = 255 + elif bits_allocated <= 16: + dtype = np.uint16 + max_value = 65535 + else: + dtype = np.uint32 + max_value = 4294967295 + + # Create a simple pattern - circle in center + y, x = np.ogrid[:rows, :columns] + center_y, center_x = rows // 2, columns // 2 + radius = min(rows, columns) // 4 + + # Create circular pattern + mask = (x - center_x) ** 2 + (y - center_y) ** 2 <= radius ** 2 + + # Generate base noise + pixel_data = np.random.randint(0, max_value // 4, (rows, columns), dtype=dtype) + + # Add circle pattern + pixel_data[mask] = max_value // 2 + + # Add some anatomical-like structures + if bits_allocated > 8: + # Add gradient for depth + gradient = np.linspace(0, max_value // 8, columns) + pixel_data += gradient.astype(dtype) + + return pixel_data + + def create_test_dicom_series(self, + study: ImagingStudy, + modality: str = 'CT', + num_images: int = 20) -> ImagingSeries: + """ + Create a test DICOM series with synthetic data. + + Args: + study: ImagingStudy instance + modality: Imaging modality + num_images: Number of images to generate + + Returns: + ImagingSeries: Created series with DICOM files + """ + # Create series + series = ImagingSeries.objects.create( + study=study, + series_instance_uid=generate_uid(), + series_number=1, + modality=modality, + series_description=f"Test {modality} Series", + series_datetime=timezone.now(), + slice_thickness=5.0 if modality in ['CT', 'MR'] else None, + spacing_between_slices=5.0 if modality in ['CT', 'MR'] else None, + pixel_spacing="0.5\\0.5" if modality in ['CT', 'MR'] else None, + ) + + # Generate DICOM files + file_paths = self.generate_series_dicoms(series, num_images) + + return series + + +class DICOMValidator: + """ + Service class for validating DICOM files and data. + """ + + @staticmethod + def validate_dicom_file(file_path: str) -> Dict[str, Any]: + """ + Validate a DICOM file and extract metadata. + + Args: + file_path: Path to DICOM file + + Returns: + dict: Validation results and metadata + """ + try: + from pydicom import dcmread + + ds = dcmread(file_path) + + return { + 'valid': True, + 'sop_instance_uid': str(ds.SOPInstanceUID), + 'sop_class_uid': str(ds.SOPClassUID), + 'study_instance_uid': str(ds.StudyInstanceUID), + 'series_instance_uid': str(ds.SeriesInstanceUID), + 'instance_number': int(ds.InstanceNumber), + 'modality': str(ds.Modality), + 'rows': int(ds.Rows), + 'columns': int(ds.Columns), + 'bits_allocated': int(ds.BitsAllocated), + 'patient_id': str(ds.PatientID), + 'study_date': str(ds.StudyDate), + 'series_number': int(ds.SeriesNumber), + 'file_size': os.path.getsize(file_path), + 'errors': [] + } + + except Exception as e: + return { + 'valid': False, + 'errors': [str(e)], + 'file_size': os.path.getsize(file_path) if os.path.exists(file_path) else 0 + } + + @staticmethod + def validate_dicom_image_model(dicom_image: DICOMImage) -> Dict[str, Any]: + """ + Validate DICOMImage model data for DICOM generation. + + Args: + dicom_image: DICOMImage model instance + + Returns: + dict: Validation results + """ + errors = [] + warnings = [] + + # Required fields + if not dicom_image.sop_instance_uid: + errors.append("SOP Instance UID is required") + + if not dicom_image.series: + errors.append("Series relationship is required") + + if dicom_image.rows <= 0: + errors.append("Rows must be positive") + + if dicom_image.columns <= 0: + errors.append("Columns must be positive") + + if dicom_image.bits_allocated not in [8, 16, 32]: + errors.append("Bits allocated must be 8, 16, or 32") + + if dicom_image.bits_stored > dicom_image.bits_allocated: + errors.append("Bits stored cannot exceed bits allocated") + + # Warnings for missing optional fields + if not dicom_image.image_position: + warnings.append("Image position not specified") + + if not dicom_image.image_orientation: + warnings.append("Image orientation not specified") + + if dicom_image.window_center is None or dicom_image.window_width is None: + warnings.append("Window/Level settings not specified") + + return { + 'valid': len(errors) == 0, + 'errors': errors, + 'warnings': warnings + } + + +class DICOMUtilities: + """ + Utility functions for DICOM operations. + """ + + @staticmethod + def generate_study_uid() -> str: + """Generate a new Study Instance UID.""" + return generate_uid() + + @staticmethod + def generate_series_uid() -> str: + """Generate a new Series Instance UID.""" + return generate_uid() + + @staticmethod + def generate_instance_uid() -> str: + """Generate a new SOP Instance UID.""" + return generate_uid() + + @staticmethod + def parse_dicom_date(date_str: str) -> Optional[date]: + """Parse DICOM date string (YYYYMMDD) to Python date.""" + try: + return datetime.strptime(date_str, '%Y%m%d').date() + except (ValueError, TypeError): + return None + + @staticmethod + def parse_dicom_time(time_str: str) -> Optional[datetime]: + """Parse DICOM time string (HHMMSS.ffffff) to Python time.""" + try: + # Handle various time formats + if '.' in time_str: + return datetime.strptime(time_str, '%H%M%S.%f').time() + else: + return datetime.strptime(time_str, '%H%M%S').time() + except (ValueError, TypeError): + return None + + @staticmethod + def format_dicom_date(date_obj: date) -> str: + """Format Python date to DICOM date string.""" + return date_obj.strftime('%Y%m%d') + + @staticmethod + def format_dicom_time(time_obj: datetime) -> str: + """Format Python datetime to DICOM time string.""" + return time_obj.strftime('%H%M%S.%f')[:-3] + + @staticmethod + def calculate_file_size_mb(file_path: str) -> float: + """Calculate file size in MB.""" + if os.path.exists(file_path): + return round(os.path.getsize(file_path) / (1024 * 1024), 2) + return 0.0 + + @staticmethod + def get_modality_description(modality: str) -> str: + """Get human-readable description for modality.""" + descriptions = { + 'CT': 'Computed Tomography', + 'MR': 'Magnetic Resonance', + 'CR': 'Computed Radiography', + 'DX': 'Digital Radiography', + 'US': 'Ultrasound', + 'XA': 'X-Ray Angiography', + 'RF': 'Radiofluoroscopy', + 'MG': 'Mammography', + 'NM': 'Nuclear Medicine', + 'PT': 'Positron Emission Tomography', + } + return descriptions.get(modality, modality) diff --git a/radiology/templates/.DS_Store b/radiology/templates/.DS_Store new file mode 100644 index 00000000..4a336efe Binary files /dev/null and b/radiology/templates/.DS_Store differ diff --git a/radiology/templates/radiology/.DS_Store b/radiology/templates/radiology/.DS_Store index 142d8479..8895d90a 100644 Binary files a/radiology/templates/radiology/.DS_Store and b/radiology/templates/radiology/.DS_Store differ diff --git a/radiology/templates/radiology/dashboard.html b/radiology/templates/radiology/dashboard.html index 56db2477..b3f50954 100644 --- a/radiology/templates/radiology/dashboard.html +++ b/radiology/templates/radiology/dashboard.html @@ -242,11 +242,11 @@ - {{ study.order.patient.first_name }} {{ study.order.patient.last_name }} + {{ study.imaging_order.patient.get_full_name }} -
{{ study.order.patient.mrn }} +
{{ study.imaging_order.patient.mrn }} - {{ study.order.modality }} + {{ study.imaging_order.modality }} {{ study.get_status_display }} @@ -293,13 +293,13 @@ - {{ report.study.order.patient.first_name }} {{ report.study.order.patient.last_name }} + {{ report.study.imaging_order.patient.get_full_name }} -
{{ report.study.order.patient.mrn }} +
{{ report.study.imaging_order.patient.mrn }} - {{ report.study.order.modality }}
- {{ report.study.order.study_description|truncatechars:20 }} + {{ report.study.imaging_order.modality }}
+ {{ report.study.study_description|truncatechars:20 }} diff --git a/radiology/templates/radiology/dicom/dicom_analysis.html b/radiology/templates/radiology/dicom/dicom_analysis.html index 9aec1170..18b1dfc2 100644 --- a/radiology/templates/radiology/dicom/dicom_analysis.html +++ b/radiology/templates/radiology/dicom/dicom_analysis.html @@ -3,8 +3,8 @@ {% block title %}DICOM Analysis - {{ file.filename }}{% endblock %} -{% block extra_css %} - +{% block css %} + -{% endblock %} - {% block content %} -
- -
-
- -

- DICOM Files Management -

+
+ +

+ DICOM Files Management + Manage and view DICOM image files +

+ + + +
+
+
+
+
+
+
{{ dicom_images.count|default:0 }}
+
Total Files
+
+
+
+
+ +
+
+
+
+
+
-
- - -{# #} -{# Upload DICOM#} -{# #} +
+
+
+
+
+
{{ processed_count|default:0 }}
+
Processed
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
{{ archived_count|default:0 }}
+
Archived
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
{{ total_size|default:"0 GB" }}
+
Storage Used
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
{{ unique_patients|default:0 }}
+
Patients
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
{{ series_count|default:0 }}
+
Series
+
+
+
+
+ +
+
+
+
+
+
+ - -
-
-
- + +
+
+

+ Filters & Search +

+ -
{{ stats.total_files|default:0 }}
-
Total Files
- -
-
- -
-
{{ stats.processed_files|default:0 }}
-
Processed
-
- -
-
- -
-
{{ stats.pending_files|default:0 }}
-
Processing
-
- -
-
- -
-
{{ stats.total_size|default:"0 GB" }}
-
Storage Used
-
- -
-
- -
-
{{ stats.unique_patients|default:0 }}
-
Patients
-
-
- - - - - -
-
- Advanced Filters -
- -
-
-
+
+ +
-
+
-
+
-
- - + + {% for series in series_list %} + + {% endfor %}
-
+
-
- -{# #} -{# Clear#} -{# #} +
+ +
+ +
-
- + +
+ - -
+ + + + + +
+ 0 files selected
-
- - - -
+ - -
-
-
- DICOM Files ({{ files|length }}) -
-
-
- - -
- + + +
- -
- - - - - - - - - - - - - - - - {% for file in files %} - - - + + + + {% empty %} + + + + {% endfor %} + +
-
- -
-
FilePatientStudy InfoModalitySizeUpload DateStatusActions
-
- -
-
-
-
- {% if file.thumbnail %} - Thumbnail +
+
+ + + + + + + + + + + + + + + + {% for image in dicom_images %} + + + - + - + + + + - - - - - - - {% empty %} - - - - {% endfor %} - -
+
+ +
+
PreviewFile InfoPatientStudy InfoSeriesSizeCreatedActions
+
+ +
+
+
+ {% if image.has_dicom_file %} + DICOM Thumbnail {% else %} - +
+ +
{% endif %}
-
-
{{ file.filename }}
-
{{ file.series_description|default:"No description" }}
-
- Instance: {{ file.instance_number|default:"N/A" }} | - Slice: {{ file.slice_location|default:"N/A" }} -
- {% if file.dicom_tags %} -
- SOP: {{ file.dicom_tags.SOPInstanceUID|truncatechars:20 }} -
- {% endif %} -
- -
-
-
- {{ file.patient_name.0|upper|default:"P" }} -
+
-
{{ file.patient_name|default:"Unknown Patient" }}
- ID: {{ file.patient_id|default:"N/A" }} +
Instance {{ image.instance_number }}
+ {{ image.rows }}x{{ image.columns }}
+ {{ image.bits_allocated }} bits + {% if image.slice_location %} +
Slice: {{ image.slice_location|floatformat:1 }}mm + {% endif %}
- -
-
-
{{ file.study_date|date:"M d, Y"|default:"N/A" }}
-
{{ file.study_description|truncatechars:30|default:"No description" }}
- {% if file.study_time %} - {{ file.study_time|time:"g:i A" }} +
+
+
+ {{ image.patient.first_name.0|upper }}{{ image.patient.last_name.0|upper }} +
+
+
{{ image.patient.get_full_name }}
+ MRN: {{ image.patient.mrn }} +
+
+
+
+
{{ image.study.study_description|truncatechars:30 }}
+ {{ image.study.accession_number }}
+ {{ image.study.study_datetime|date:"M d, Y" }} +
+
+
+ + {{ image.series.modality }} + +
+ Series {{ image.series.series_number }}
+ {{ image.series.series_description|truncatechars:20 }} +
+
+
+
{{ image.file_size_mb }} MB
+ {% if image.processed %} + + Processed + + {% else %} + + Pending + {% endif %} - -
- - {{ file.modality|default:"Unknown" }} - - -
{{ file.file_size|filesizeformat }}
- {% if file.compression_ratio %} - {{ file.compression_ratio }}% compressed - {% endif %} -
-
{{ file.uploaded_at|date:"M d, Y" }}
- {{ file.uploaded_at|time:"g:i A" }} -
- - {{ file.get_status_display }} - - {% if file.status == 'processing' %} -
-
-
- {% endif %} -
-
- - - - {% if file.can_edit %} - - {% endif %} - {% if file.can_delete %} - - {% endif %} -
-
-
- -
No DICOM Files Found
-

No DICOM files match your current filters.

-{# #} -{# Upload First File#} -{# #} -
-
-
- - - {% if is_paginated %} -
-
- Showing {{ files|length }} of {{ total_files }} files +
+
{{ image.created_at|date:"M d, Y" }}
+ {{ image.created_at|time:"g:i A" }} +
+ +
+
+ +
No DICOM Files Found
+

No DICOM files match your current filters.

+
+
- -
- {% endif %} -
-
- - - - - - {% endblock %} {% block js %} - - - - - {% endblock %} - diff --git a/radiology/templates/radiology/dicom/dicom_metadata_editor.html b/radiology/templates/radiology/dicom/dicom_metadata_editor.html index 2908220d..beafeb6c 100644 --- a/radiology/templates/radiology/dicom/dicom_metadata_editor.html +++ b/radiology/templates/radiology/dicom/dicom_metadata_editor.html @@ -3,8 +3,8 @@ {% block title %}DICOM Metadata Editor - {{ file.filename }}{% endblock %} -{% block extra_css %} - +{% block css %} + {% endblock %} {% block content %} -
-
- -

DICOM Viewer

-
-
- - {% if study %} - - Back to Study - - {% endif %} -
-
- -
-
-
- -
-
-
-
- - - +
+
+
+ +
+ +
+
Patient Information
+
+
+ Name: {{ dicom_image.patient.get_full_name }}
- -
- - - +
+ MRN: {{ dicom_image.patient.mrn }}
- -
- - - +
+ DOB: {{ dicom_image.patient.date_of_birth }}
- -
- - - +
+ Gender: {{ dicom_image.patient.get_gender_display }}
+
-
- Image 1 of 1 -
- + {% endif %} + +
+ Image {{ current_index|add:1 }} of {{ total_images }} +
+ + {% if next_image %} + + Next + + {% else %} + + {% endif %} + + + Series View + + + + Download DICOM + +
+ + +
+ + + {% if dicom_image.has_dicom_file %} + DICOM Image {{ dicom_image.instance_number }} + {% else %} +
+ + DICOM file not found for this image. +
+ {% endif %} +
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + -
- - {% if study %} -
-
{{ study.patient.get_full_name }}
-
ID: {{ study.patient.patient_id }}
-
DOB: {{ study.patient.date_of_birth|date:"M d, Y" }}
-
Gender: {{ study.patient.get_gender_display }}
-
- {% endif %} - - - {% if study %} -
-
{{ study.modality }}
-
{{ study.study_date|date:"M d, Y" }}
-
{{ study.description|default:"No description" }}
-
Accession: {{ study.accession_number }}
-
- {% endif %} - - -
-
- Loading... -
-
Loading DICOM images...
-
- - -
- - - - - - -
- - -
-
Series
- {% if series_list %} - {% for series in series_list %} -
-
-
{{ series.series_number }}
-
{{ series.description|truncatechars:20 }}
-
{{ series.image_count }} images
-
+ +
+ - -
-
-
-
- - - 1/1 -
-
-
-
- - - 400 -
-
-
-
- - - 40 + +
+
+
@@ -321,439 +362,160 @@
- - - - - - - - - - - - {% endblock %} +{% block js %} + +{% endblock %} diff --git a/radiology/templates/radiology/dicom/dicom_workflow.html b/radiology/templates/radiology/dicom/dicom_workflow.html index f55aefbb..8e1eb037 100644 --- a/radiology/templates/radiology/dicom/dicom_workflow.html +++ b/radiology/templates/radiology/dicom/dicom_workflow.html @@ -3,8 +3,8 @@ {% block title %}DICOM Workflow Management{% endblock %} -{% block extra_css %} - +{% block css %} + +{% endblock %} + +{% block content %} +
+
+ +
+
+
+

{{ series.series_description|default:"Series" }} ({{ series.modality }})

+

Patient: {{ series.study.patient.get_full_name }} | + Study: {{ series.study.study_description }} | + Accession: {{ series.study.accession_number }}

+
+
+
+ 1 of {{ image_count }} images +
+
+
+
+ + +
+ + +
+ + +
+
+ + + {% if first_image %} + DICOM Image + {% else %} +
+ + No images found in this series. +
+ {% endif %} +
+ + +
+ + + + +
+ + + +
+ + + + + + + Back to Study + +
+ + +
+ +
+
+ + + +
+
+{% endblock %} + +{% block js %} + +{% endblock %} diff --git a/radiology/templates/radiology/image_viewer.html b/radiology/templates/radiology/image_viewer.html index aa879389..dc97c20b 100644 --- a/radiology/templates/radiology/image_viewer.html +++ b/radiology/templates/radiology/image_viewer.html @@ -3,6 +3,187 @@ {% block title %}DICOM Image Viewer - Radiology{% endblock %} +{% block css %} + +{% endblock %} + {% block content %}
@@ -193,9 +374,9 @@
-
{{ study.patient_name|default:"Smith, John" }}
-
{{ study.patient_mrn|default:"MRN: 123456" }}
-
{{ study.study_date|default:"2024-01-15" }}
+
{{ study.patient.get_full_name }}
+
{{ study.patient.mrn }}
+
{{ study.study_date }}
@@ -393,7 +574,8 @@
- +{% endblock %} +{% block js %} - - {% endblock %} + diff --git a/radiology/templates/radiology/orders/imaging_order_detail.html b/radiology/templates/radiology/orders/imaging_order_detail.html index 4b881866..f8890706 100644 --- a/radiology/templates/radiology/orders/imaging_order_detail.html +++ b/radiology/templates/radiology/orders/imaging_order_detail.html @@ -354,7 +354,7 @@
- View Full Report @@ -582,7 +582,7 @@ function scheduleOrder() { function startStudy() { if (confirm('Start the imaging study for this order?')) { $.ajax({ - url: '{% url "radiology:start_study" imaging_order.order_id%}', + url: '{% url "radiology:start_study" order.order_id%}', method: 'POST', data: { 'order_id': '{{ object.id }}', @@ -606,7 +606,7 @@ function startStudy() { function completeStudy() { if (confirm('Mark the imaging study as completed?')) { $.ajax({ - url: '{% url "radiology:complete_study" imaging_order.order_id%}', + url: '{% url "radiology:complete_study" order.order_id%}', method: 'POST', data: { 'order_id': '{{ object.id }}', diff --git a/radiology/templates/radiology/series/imaging_series_detail.html b/radiology/templates/radiology/series/imaging_series_detail.html index 98da9dc1..4b20ccf0 100644 --- a/radiology/templates/radiology/series/imaging_series_detail.html +++ b/radiology/templates/radiology/series/imaging_series_detail.html @@ -15,10 +15,10 @@
Series {{ series.series_number }} - {{ series.series_description|default:"No Description" }}
- + Back to Series List - + Edit Series
@@ -27,7 +27,7 @@