updates
This commit is contained in:
parent
60839790e8
commit
01fa26c59a
281
PERMISSION_UPDATES_SUMMARY.md
Normal file
281
PERMISSION_UPDATES_SUMMARY.md
Normal file
@ -0,0 +1,281 @@
|
||||
# Permission Updates Summary
|
||||
|
||||
This document summarizes all the permission decorator updates made to secure the PX360 application.
|
||||
|
||||
## New Decorators Created (`apps/core/decorators.py`)
|
||||
|
||||
| Decorator | Description | Access Level |
|
||||
|-----------|-------------|--------------|
|
||||
| `@px_admin_required` | PX Admins only | Level 100 |
|
||||
| `@hospital_admin_required` | PX Admins + Hospital Admins | Level 80+ |
|
||||
| `@admin_required` | Any admin (PX, Hospital, Dept Manager) | Level 60+ |
|
||||
| `@px_coordinator_required` | Coordinators and above | Level 50+ |
|
||||
| `@staff_required` | All staff except Source Users | Level 10+ |
|
||||
| `@source_user_required` | Source Users only | Level 5 |
|
||||
| `@block_source_user` | Blocks Source Users | Blocks Level 5 |
|
||||
| `@source_user_or_admin` | Source Users OR Admins | Level 5+ or 60+ |
|
||||
|
||||
---
|
||||
|
||||
## Views Updated with Permission Decorators
|
||||
|
||||
### 1. Dashboard Views (`apps/dashboard/views.py`)
|
||||
|
||||
| View | Original | Updated |
|
||||
|------|----------|---------|
|
||||
| `admin_evaluation` | `@login_required` | Added permission check inside |
|
||||
| `admin_evaluation_chart_data` | `@login_required` | Added permission check inside |
|
||||
| `staff_performance_detail` | `@login_required` | Added permission check inside |
|
||||
| `department_benchmarks` | `@login_required` | Added permission check inside |
|
||||
| `export_staff_performance` | `@login_required` | Added permission check inside |
|
||||
| `performance_analytics_api` | `@login_required` | Added permission check inside |
|
||||
| `staff_performance_trends` | `@login_required` | Added permission check inside |
|
||||
|
||||
**Access:** PX Admin and Hospital Admin only ❌ PX Coordinator
|
||||
|
||||
---
|
||||
|
||||
### 2. Analytics Views (`apps/analytics/ui_views.py`)
|
||||
|
||||
| View | Original | Updated |
|
||||
|------|----------|---------|
|
||||
| `analytics_dashboard` | `@login_required` | `@block_source_user` + `@login_required` |
|
||||
| `kpi_list` | `@login_required` | `@block_source_user` + `@login_required` |
|
||||
| `command_center` | `@login_required` | `@block_source_user` + `@login_required` |
|
||||
| `command_center_api` | `@login_required` | `@block_source_user` + `@login_required` |
|
||||
| `export_command_center` | `@login_required` | `@block_source_user` + `@login_required` |
|
||||
|
||||
**Access:** All staff except Source Users
|
||||
|
||||
---
|
||||
|
||||
### 3. Surveys Views (`apps/surveys/ui_views.py`)
|
||||
|
||||
All 22 views updated:
|
||||
- `@login_required` → `@block_source_user` + `@login_required`
|
||||
|
||||
**Access:** All staff except Source Users
|
||||
|
||||
---
|
||||
|
||||
### 4. Organizations Views (`apps/organizations/ui_views.py`)
|
||||
|
||||
All views updated:
|
||||
- `@login_required` → `@block_source_user` + `@login_required`
|
||||
|
||||
**Access:** All staff except Source Users
|
||||
|
||||
---
|
||||
|
||||
### 5. Complaints Views (`apps/complaints/ui_views.py`)
|
||||
|
||||
| View | Original | Updated | Access |
|
||||
|------|----------|---------|--------|
|
||||
| `complaint_list` | `@login_required` | No change (has RBAC filtering) | All users (filtered) |
|
||||
| `complaint_create` | `@login_required` | No change | All staff + Source Users |
|
||||
| `complaint_assign` | `@login_required` | `@hospital_admin_required` | Admin only |
|
||||
| `complaint_activate` | `@login_required` | Permission check inside | Admin + Dept Manager |
|
||||
| `complaint_escalate` | `@login_required` | Permission check inside | Admin only |
|
||||
| `complaint_bulk_assign` | `@login_required` | `@hospital_admin_required` | Admin only |
|
||||
| `complaint_bulk_status` | `@login_required` | `@hospital_admin_required` | Admin only |
|
||||
| `complaint_bulk_escalate` | `@login_required` | `@hospital_admin_required` | Admin only |
|
||||
|
||||
---
|
||||
|
||||
### 6. Config Views (`apps/core/config_views.py`)
|
||||
|
||||
| View | Original | Updated |
|
||||
|------|----------|---------|
|
||||
| `config_dashboard` | `@login_required` | `@px_admin_required` |
|
||||
| `sla_config_list` | `@login_required` | `@px_admin_required` |
|
||||
| `routing_rules_list` | `@login_required` | `@px_admin_required` |
|
||||
|
||||
**Access:** PX Admin only
|
||||
|
||||
---
|
||||
|
||||
### 7. PX Sources Views (`apps/px_sources/ui_views.py`)
|
||||
|
||||
Already had proper decorators:
|
||||
- Admin views: `@block_source_user`
|
||||
- Source User views: `@source_user_required`
|
||||
|
||||
---
|
||||
|
||||
## Permission Enforcement Summary by Role
|
||||
|
||||
### PX Admin (Level 100)
|
||||
✅ Full access to all views and functions
|
||||
|
||||
### Hospital Admin (Level 80)
|
||||
✅ Can access:
|
||||
- Admin Evaluation (own hospital)
|
||||
- Staff Management (own hospital)
|
||||
- Complaint assignment/activation
|
||||
- Survey management
|
||||
- Analytics and reports
|
||||
- Settings (hospital-level)
|
||||
|
||||
❌ Cannot access:
|
||||
- PX Admin-only config (system settings)
|
||||
- Other hospitals' data
|
||||
|
||||
### Department Manager (Level 60)
|
||||
✅ Can access:
|
||||
- Department complaints
|
||||
- Department staff
|
||||
- Department analytics
|
||||
|
||||
❌ Cannot access:
|
||||
- Admin Evaluation
|
||||
- Bulk actions
|
||||
- Complaint assignment
|
||||
- Settings
|
||||
|
||||
### PX Coordinator (Level 50)
|
||||
✅ Can access:
|
||||
- Complaints (create, manage - but NOT assign/activate)
|
||||
- PX Actions
|
||||
- Surveys
|
||||
- Analytics (basic)
|
||||
|
||||
❌ Cannot access:
|
||||
- **Admin Evaluation** (NEW)
|
||||
- Staff Management
|
||||
- Settings
|
||||
- Complaint assignment/activation
|
||||
|
||||
### Source User (Level 5)
|
||||
✅ Can access:
|
||||
- Create complaints (their own)
|
||||
- Create inquiries (their own)
|
||||
- View own created complaints/inquiries
|
||||
- **Automatically redirected to `/px-sources/dashboard/` when visiting `/` or `/dashboard/my/`**
|
||||
|
||||
❌ Cannot access:
|
||||
- **Surveys** (NEW - blocked → redirected)
|
||||
- **Analytics** (NEW - blocked → redirected)
|
||||
- **Staff/Organizations** (NEW - blocked → redirected)
|
||||
- **Settings** (NEW - blocked → redirected)
|
||||
- **PX Actions** (NEW - blocked → redirected)
|
||||
- **Acknowledgements** (NEW - blocked → redirected)
|
||||
- **Command Center** (`/` - redirected to source dashboard)
|
||||
- **My Dashboard** (`/dashboard/my/` - redirected to source dashboard)
|
||||
|
||||
---
|
||||
|
||||
## Key Security Fixes
|
||||
|
||||
1. **Fixed**: PX Coordinator could access Admin Evaluation (now blocked)
|
||||
2. **Fixed**: Source Users could access Surveys (now blocked)
|
||||
3. **Fixed**: Source Users could access Analytics (now blocked)
|
||||
4. **Fixed**: Source Users could access Staff Management (now blocked)
|
||||
5. **Fixed**: Source Users could access Settings (now blocked)
|
||||
|
||||
---
|
||||
|
||||
## Source User Strict Access Control
|
||||
|
||||
**STRICT POLICY**: Source Users can ONLY access:
|
||||
1. `/px-sources/*` - Their dashboard, complaints, and inquiries
|
||||
2. `/accounts/password/change/` - Password change
|
||||
3. `/accounts/settings/` - Basic settings
|
||||
4. `/accounts/logout/` - Logout
|
||||
|
||||
**ALL other pages are BLOCKED and redirected to `/px-sources/dashboard/`**
|
||||
|
||||
### Middleware Enforcement
|
||||
|
||||
The `SourceUserRestrictionMiddleware` enforces this at the request level:
|
||||
- Checks every request from source users
|
||||
- Only allows whitelisted paths
|
||||
- Silently redirects to source dashboard for blocked paths
|
||||
- Runs after authentication middleware
|
||||
|
||||
### Allowed URLs for Source Users:
|
||||
|
||||
| URL | Access |
|
||||
|-----|--------|
|
||||
| `/px-sources/dashboard/` | ✅ Yes |
|
||||
| `/px-sources/complaints/` | ✅ Yes |
|
||||
| `/px-sources/inquiries/` | ✅ Yes |
|
||||
| `/px-sources/complaints/new/` | ✅ Yes |
|
||||
| `/px-sources/inquiries/new/` | ✅ Yes |
|
||||
| `/accounts/password/change/` | ✅ Yes |
|
||||
| `/accounts/settings/` | ✅ Yes |
|
||||
| `/accounts/logout/` | ✅ Yes |
|
||||
| `/` (root) | ❌ **Redirected** |
|
||||
| `/dashboard/my/` | ❌ **Redirected** |
|
||||
| `/surveys/*` | ❌ **Redirected** |
|
||||
| `/analytics/*` | ❌ **Redirected** |
|
||||
| `/organizations/*` | ❌ **Redirected** |
|
||||
| `/config/*` | ❌ **Redirected** |
|
||||
| `/actions/*` | ❌ **Redirected** |
|
||||
| `/complaints/` (main list) | ❌ **Redirected** |
|
||||
| `/complaints/inquiries/` (main) | ❌ **Redirected** |
|
||||
|
||||
### Technical Implementation
|
||||
|
||||
```python
|
||||
# SourceUserRestrictionMiddleware
|
||||
ALLOWED_PATH_PREFIXES = ['/px-sources/']
|
||||
ALLOWED_URL_NAMES = {
|
||||
'accounts:password_change',
|
||||
'accounts:settings',
|
||||
'accounts:logout',
|
||||
}
|
||||
|
||||
# Everything else is BLOCKED for source users
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] PX Admin can access everything
|
||||
- [ ] Hospital Admin can access their hospital data only
|
||||
- [ ] Department Manager can access their department only
|
||||
- [ ] PX Coordinator CANNOT access Admin Evaluation
|
||||
- [ ] PX Coordinator can create complaints but NOT assign them
|
||||
- [ ] **Source User visiting `/` gets redirected to `/px-sources/dashboard/`**
|
||||
- [ ] **Source User visiting `/dashboard/my/` gets redirected to `/px-sources/dashboard/`**
|
||||
- [ ] Source User can create/view their own complaints only
|
||||
- [ ] Source User CANNOT access Surveys (redirects to their dashboard)
|
||||
- [ ] Source User CANNOT access Analytics (redirects to their dashboard)
|
||||
- [ ] Source User CANNOT access Staff Management (redirects to their dashboard)
|
||||
- [ ] Source User CANNOT access Settings (redirects to their dashboard)
|
||||
|
||||
---
|
||||
|
||||
## Decorator Usage Examples
|
||||
|
||||
```python
|
||||
# PX Admin only
|
||||
@px_admin_required
|
||||
def system_settings(request):
|
||||
pass
|
||||
|
||||
# Hospital Admin and above
|
||||
@hospital_admin_required
|
||||
def hospital_settings(request):
|
||||
pass
|
||||
|
||||
# Any admin
|
||||
@admin_required
|
||||
def department_management(request):
|
||||
pass
|
||||
|
||||
# Block source users
|
||||
@block_source_user
|
||||
def staff_list(request):
|
||||
pass
|
||||
|
||||
# Source users only
|
||||
@source_user_required
|
||||
def source_dashboard(request):
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-02-25
|
||||
@ -47,6 +47,9 @@ MIDDLEWARE = [
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
# PX Source User Access Control
|
||||
'apps.px_sources.middleware.SourceUserRestrictionMiddleware',
|
||||
'apps.px_sources.middleware.SourceUserSessionMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'PX360.urls'
|
||||
@ -62,6 +65,8 @@ TEMPLATES = [
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'apps.core.context_processors.hospital_context',
|
||||
'apps.core.context_processors.sidebar_counts',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
62
SIMPLE_ACKNOWLEDGEMENT_SUMMARY.md
Normal file
62
SIMPLE_ACKNOWLEDGEMENT_SUMMARY.md
Normal file
@ -0,0 +1,62 @@
|
||||
# Simple Acknowledgement System - Implementation Complete
|
||||
|
||||
## Summary
|
||||
The old complex acknowledgement system has been removed and replaced with a simplified version.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Removed Old Implementation
|
||||
- Deleted `/apps/accounts/acknowledgement_views.py`
|
||||
- Deleted `/apps/accounts/urls_acknowledgement.py`
|
||||
- Updated `/apps/accounts/urls.py` to remove old acknowledgement URL paths
|
||||
- Simplified sidebar navigation in `/templates/layouts/partials/sidebar.html`
|
||||
|
||||
### 2. Simple Acknowledgement System
|
||||
**Models** (in `/apps/accounts/models.py`):
|
||||
- `SimpleAcknowledgement` - The acknowledgement items
|
||||
- `EmployeeAcknowledgement` - Records of employee signatures
|
||||
|
||||
**Views** (in `/apps/accounts/simple_acknowledgement_views.py`):
|
||||
- Employee: list, sign, download PDF
|
||||
- Admin: list, create, edit, delete, view signatures, export
|
||||
|
||||
**Templates** (in `/templates/accounts/simple_acknowledgements/`):
|
||||
- `list.html` - Employee checklist with modern styling
|
||||
- `sign.html` - Sign form with name and employee ID fields
|
||||
- `admin_list.html` - Admin management with stats cards
|
||||
- `admin_form.html` - Create/edit form with file upload
|
||||
- `admin_delete.html` - Delete confirmation
|
||||
- `admin_signatures.html` - View all signatures with filtering
|
||||
|
||||
**URLs** (in `/apps/accounts/simple_acknowledgement_urls.py`):
|
||||
- Employee: `/accounts/simple-acknowledgements/my-acknowledgements/`
|
||||
- Sign: `/accounts/simple-acknowledgements/my-acknowledgements/sign/<uuid>/`
|
||||
- Admin: `/accounts/simple-acknowledgements/admin/acknowledgements/`
|
||||
|
||||
### 3. Styling
|
||||
All templates use the modern PX360 design system:
|
||||
- Gradient backgrounds
|
||||
- Card-based layouts
|
||||
- Tailwind CSS styling
|
||||
- Lucide icons
|
||||
- Responsive design
|
||||
|
||||
## Features
|
||||
1. ✅ **Checklist** - Employees see all acknowledgements with checkmarks
|
||||
2. ✅ **Add Future Acknowledgements** - Admins can create new ones anytime
|
||||
3. ✅ **Employee & Employee ID** - Required fields when signing
|
||||
4. ✅ **PDF Support** - Upload documents and download signed PDFs
|
||||
5. ✅ **Export** - Export signatures to CSV
|
||||
|
||||
## Navigation
|
||||
- Employees: Sidebar → "Acknowledgements"
|
||||
- PX Admins: Same link, plus access to admin management
|
||||
|
||||
## Migration
|
||||
Run: `python manage.py migrate accounts`
|
||||
|
||||
## Next Steps
|
||||
1. Start the server: `python manage.py runserver`
|
||||
2. Access: http://localhost:8000/accounts/simple-acknowledgements/my-acknowledgements/
|
||||
3. As PX Admin, create acknowledgements first
|
||||
4. Employees can then view and sign
|
||||
92
SIMPLE_ACKNOWLEDGEMENT_SYSTEM.md
Normal file
92
SIMPLE_ACKNOWLEDGEMENT_SYSTEM.md
Normal file
@ -0,0 +1,92 @@
|
||||
# Simple Acknowledgement System - Implementation Summary
|
||||
|
||||
## Overview
|
||||
A simplified acknowledgement system for employees to sign required documents.
|
||||
|
||||
## Features
|
||||
1. **Checklist of Acknowledgements** - Employees see a checklist of all acknowledgements they must sign
|
||||
2. **Add Future Acknowledgements** - Admins can add new acknowledgements anytime
|
||||
3. **Employee & Employee ID** - Employees sign with their name and employee ID
|
||||
4. **Checkmark & PDF** - Signed acknowledgements show checkmarks and have attached PDFs
|
||||
|
||||
## Models
|
||||
|
||||
### SimpleAcknowledgement
|
||||
- `title` - Acknowledgement title
|
||||
- `description` - Detailed description
|
||||
- `pdf_document` - PDF document for employees to review
|
||||
- `is_active` - Show in employee checklist
|
||||
- `is_required` - Must be signed by all employees
|
||||
- `order` - Display order
|
||||
|
||||
### EmployeeAcknowledgement
|
||||
- `employee` - ForeignKey to User
|
||||
- `acknowledgement` - ForeignKey to SimpleAcknowledgement
|
||||
- `is_signed` - Boolean flag
|
||||
- `signed_at` - Timestamp
|
||||
- `signature_name` - Name used when signing
|
||||
- `signature_employee_id` - Employee ID when signing
|
||||
- `signed_pdf` - Generated PDF with signature
|
||||
- `ip_address` - For audit trail
|
||||
- `user_agent` - For audit trail
|
||||
|
||||
## URLs
|
||||
|
||||
### Employee URLs
|
||||
- `/accounts/simple-acknowledgements/my-acknowledgements/` - Employee checklist
|
||||
- `/accounts/simple-acknowledgements/my-acknowledgements/sign/<uuid>/` - Sign acknowledgement
|
||||
- `/accounts/simple-acknowledgements/my-acknowledgements/download/<uuid>/` - Download signed PDF
|
||||
|
||||
### Admin URLs
|
||||
- `/accounts/simple-acknowledgements/admin/acknowledgements/` - List all acknowledgements
|
||||
- `/accounts/simple-acknowledgements/admin/acknowledgements/new/` - Create new acknowledgement
|
||||
- `/accounts/simple-acknowledgements/admin/acknowledgements/<uuid>/edit/` - Edit acknowledgement
|
||||
- `/accounts/simple-acknowledgements/admin/acknowledgements/<uuid>/delete/` - Delete acknowledgement
|
||||
- `/accounts/simple-acknowledgements/admin/acknowledgements/<uuid>/toggle/` - Toggle active status
|
||||
- `/accounts/simple-acknowledgements/admin/signatures/` - View all signatures
|
||||
- `/accounts/simple-acknowledgements/admin/signatures/employee/<uuid>/` - View employee's signatures
|
||||
|
||||
## Templates
|
||||
All templates are in `templates/accounts/simple_acknowledgements/`:
|
||||
- `employee_list.html` - Employee checklist view
|
||||
- `sign.html` - Sign acknowledgement form
|
||||
- `admin_list.html` - Admin list view
|
||||
- `admin_form.html` - Admin create/edit form
|
||||
- `admin_signatures.html` - Admin signatures view
|
||||
|
||||
## Migration
|
||||
Run the migration to create the new tables:
|
||||
```bash
|
||||
python manage.py migrate accounts
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### For Employees
|
||||
1. Navigate to `/accounts/simple-acknowledgements/my-acknowledgements/`
|
||||
2. See list of pending and completed acknowledgements
|
||||
3. Click "Sign Now" to sign a pending acknowledgement
|
||||
4. Enter name and employee ID, then submit
|
||||
5. Download signed PDF if needed
|
||||
|
||||
### For Admins
|
||||
1. Navigate to `/accounts/simple-acknowledgements/admin/acknowledgements/`
|
||||
2. Click "New Acknowledgement" to create
|
||||
3. Fill in title, description, and optionally upload a PDF
|
||||
4. Set as active/required as needed
|
||||
5. View signatures in the admin signatures page
|
||||
|
||||
## Navigation Link (Optional)
|
||||
Add to your navigation menu:
|
||||
```html
|
||||
<a href="{% url 'accounts:simple_acknowledgements:employee_list' %}">
|
||||
My Acknowledgements
|
||||
</a>
|
||||
```
|
||||
|
||||
For PX Admins:
|
||||
```html
|
||||
<a href="{% url 'accounts:simple_acknowledgements:admin_list' %}">
|
||||
Manage Acknowledgements
|
||||
</a>
|
||||
```
|
||||
150
SOURCE_USER_STRICT_ACCESS.md
Normal file
150
SOURCE_USER_STRICT_ACCESS.md
Normal file
@ -0,0 +1,150 @@
|
||||
# PX Source User - Strict Access Control
|
||||
|
||||
## Overview
|
||||
|
||||
PX Source Users have **STRICT LIMITED ACCESS** to the system. They can ONLY access:
|
||||
1. `/px-sources/*` pages (their dedicated portal)
|
||||
2. Password change page
|
||||
3. Basic settings page
|
||||
4. Logout
|
||||
|
||||
**ALL other URLs are blocked and redirected to `/px-sources/dashboard/`**
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### 1. Middleware (`apps/px_sources/middleware.py`)
|
||||
|
||||
```python
|
||||
class SourceUserRestrictionMiddleware:
|
||||
"""
|
||||
STRICT middleware that restricts source users to ONLY:
|
||||
1. /px-sources/* pages
|
||||
2. Password change
|
||||
3. Settings
|
||||
4. Logout
|
||||
"""
|
||||
|
||||
ALLOWED_PATH_PREFIXES = ['/px-sources/']
|
||||
ALLOWED_URL_NAMES = {
|
||||
'accounts:password_change',
|
||||
'accounts:settings',
|
||||
'accounts:logout',
|
||||
'accounts:login',
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Settings Configuration
|
||||
|
||||
Added to `config/settings/base.py`:
|
||||
```python
|
||||
MIDDLEWARE = [
|
||||
...
|
||||
'apps.px_sources.middleware.SourceUserRestrictionMiddleware',
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
### 3. View-Level Enforcement
|
||||
|
||||
Updated views to redirect source users:
|
||||
- `CommandCenterView.dispatch()` - redirects to `/px-sources/dashboard/`
|
||||
- `my_dashboard()` - redirects to `/px-sources/dashboard/`
|
||||
- `@block_source_user` decorator - redirects to `/px-sources/dashboard/`
|
||||
|
||||
---
|
||||
|
||||
## Allowed URLs for Source Users
|
||||
|
||||
| URL | Access | Description |
|
||||
|-----|--------|-------------|
|
||||
| `/px-sources/dashboard/` | ✅ | Source User Dashboard |
|
||||
| `/px-sources/complaints/` | ✅ | List their complaints |
|
||||
| `/px-sources/complaints/new/` | ✅ | Create new complaint |
|
||||
| `/px-sources/inquiries/` | ✅ | List their inquiries |
|
||||
| `/px-sources/inquiries/new/` | ✅ | Create new inquiry |
|
||||
| `/accounts/password/change/` | ✅ | Change password |
|
||||
| `/accounts/settings/` | ✅ | Basic settings |
|
||||
| `/accounts/logout/` | ✅ | Logout |
|
||||
| `/static/*` | ✅ | Static files (CSS/JS) |
|
||||
| `/media/*` | ✅ | Media files |
|
||||
| `/i18n/*` | ✅ | Language switching |
|
||||
|
||||
---
|
||||
|
||||
## Blocked URLs (Redirected to `/px-sources/dashboard/`)
|
||||
|
||||
| URL | Blocked | Behavior |
|
||||
|-----|---------|----------|
|
||||
| `/` | ✅ | Redirected |
|
||||
| `/dashboard/my/` | ✅ | Redirected |
|
||||
| `/dashboard/admin-evaluation/` | ✅ | Redirected |
|
||||
| `/analytics/*` | ✅ | Redirected |
|
||||
| `/surveys/*` | ✅ | Redirected |
|
||||
| `/complaints/` | ✅ | Redirected |
|
||||
| `/complaints/inquiries/` | ✅ | Redirected |
|
||||
| `/organizations/*` | ✅ | Redirected |
|
||||
| `/config/*` | ✅ | Redirected |
|
||||
| `/actions/*` | ✅ | Redirected |
|
||||
| `/physicians/*` | ✅ | Redirected |
|
||||
| `/px-sources/` (admin pages) | ✅ | Redirected |
|
||||
| `/px-sources/new/` | ✅ | Redirected |
|
||||
| `/px-sources/<id>/edit/` | ✅ | Redirected |
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Cases
|
||||
|
||||
1. **Login as Source User**
|
||||
- Visit: `/`
|
||||
- Expected: Redirected to `/px-sources/dashboard/`
|
||||
|
||||
2. **Try to access Command Center**
|
||||
- Visit: `/dashboard/my/`
|
||||
- Expected: Redirected to `/px-sources/dashboard/`
|
||||
|
||||
3. **Try to access Surveys**
|
||||
- Visit: `/surveys/`
|
||||
- Expected: Redirected to `/px-sources/dashboard/`
|
||||
|
||||
4. **Try to access Staff**
|
||||
- Visit: `/organizations/staff/`
|
||||
- Expected: Redirected to `/px-sources/dashboard/`
|
||||
|
||||
5. **Access Source User Portal**
|
||||
- Visit: `/px-sources/dashboard/`
|
||||
- Expected: ✅ Works!
|
||||
|
||||
6. **Access Password Change**
|
||||
- Visit: `/accounts/password/change/`
|
||||
- Expected: ✅ Works!
|
||||
|
||||
7. **Create Complaint**
|
||||
- Visit: `/px-sources/complaints/new/`
|
||||
- Expected: ✅ Works!
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
1. **Middleware runs on EVERY request** - Cannot be bypassed
|
||||
2. **No error messages** - Silent redirect for better UX
|
||||
3. **Whitelist approach** - Only explicitly allowed URLs work
|
||||
4. **Superusers excluded** - Superusers bypass all restrictions
|
||||
5. **Static files allowed** - Required for CSS/JS to work
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. `apps/px_sources/middleware.py` - Updated `SourceUserRestrictionMiddleware`
|
||||
2. `config/settings/base.py` - Added middleware to MIDDLEWARE list
|
||||
3. `apps/dashboard/views.py` - Added redirects in views
|
||||
4. `apps/core/decorators.py` - Updated `@block_source_user` decorator
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2026-02-25
|
||||
341
USER_ACCESS_MATRIX.md
Normal file
341
USER_ACCESS_MATRIX.md
Normal file
@ -0,0 +1,341 @@
|
||||
# PX360 User Access Matrix
|
||||
|
||||
This document outlines which user roles can access which pages and features in the PX360 system.
|
||||
|
||||
## Role Hierarchy (High to Low)
|
||||
|
||||
| Role | Level | Description |
|
||||
|------|-------|-------------|
|
||||
| **PX Admin** | 100 | Full system access across all hospitals |
|
||||
| **Hospital Admin** | 80 | Full access within their assigned hospital |
|
||||
| **Department Manager** | 60 | Access to their department and sub-departments |
|
||||
| **PX Coordinator** | 50 | Manages complaints, actions, and surveys |
|
||||
| **Physician** | 40 | View patient feedback and own ratings |
|
||||
| **Nurse/Staff** | 30/20 | Basic staff access to department data |
|
||||
| **Viewer** | 10 | Read-only access to reports |
|
||||
| **PX Source User** | 5 | External users - create/view only their own complaints/inquiries |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Quick Reference: Role Capabilities
|
||||
|
||||
| Feature | PX Admin | Hospital Admin | Dept Manager | PX Coord | Physician | Staff | Viewer | Source User |
|
||||
|---------|:--------:|:--------------:|:------------:|:--------:|:---------:|:-----:|:------:|:-----------:|
|
||||
| **Dashboard (Command Center)** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **❌ (Redirected to /px-sources/)** |
|
||||
| **All Hospitals** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| **All Complaints** | ✅ | ✅ Hospital | ✅ Dept | ✅ Hospital | ❌ | ❌ | ✅ | ✅ Own |
|
||||
| **Create Complaint** | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
|
||||
| **Assign Complaints** | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| **Surveys** | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
| **Staff Management** | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| **Settings** | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| **Analytics** | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ |
|
||||
| **Admin Evaluation** | ✅ | ✅ | ❌ | **❌** | ❌ | ❌ | ❌ | ❌ |
|
||||
| **PX Actions** | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## 📊 Detailed Access by Module
|
||||
|
||||
### 1. DASHBOARD & ANALYTICS
|
||||
|
||||
| Page/Feature | PX Admin | Hospital Admin | Dept Manager | PX Coordinator | Physician | Staff | Viewer | Source User |
|
||||
|-------------|:--------:|:--------------:|:------------:|:--------------:|:---------:|:-----:|:------:|:-----------:|
|
||||
| `/` - Command Center Dashboard | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **❌ (Redirected)** |
|
||||
| `/dashboard/my/` - My Dashboard | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **❌ (Redirected)** |
|
||||
| `/dashboard/admin-evaluation/` | ✅ | ✅ | ❌ | **❌** | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
| `/dashboard/admin-evaluation/staff/<id>/` | ✅ | ✅ | ❌ | **❌** | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
| `/dashboard/admin-evaluation/benchmarks/` | ✅ | ✅ | ❌ | **❌** | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
| `/analytics/dashboard/` | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | **❌ (Redirected)** |
|
||||
| `/analytics/kpi-reports/` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | **❌ (Redirected)** |
|
||||
| `/analytics/command-center/` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **❌ (Redirected)** |
|
||||
|
||||
**Notes:**
|
||||
- **PX Admin**: Can switch between all hospitals
|
||||
- **Source User**: **STRICT ACCESS** - Can ONLY access `/px-sources/*` and password change. All other pages redirect to `/px-sources/dashboard/`
|
||||
- **My Dashboard**: Shows items assigned to the user (complaints, inquiries, actions, tasks)
|
||||
|
||||
---
|
||||
|
||||
### 2. COMPLAINTS MODULE
|
||||
|
||||
| Page/Feature | PX Admin | Hospital Admin | Dept Manager | PX Coordinator | Physician | Staff | Viewer | Source User |
|
||||
|-------------|:--------:|:--------------:|:------------:|:--------------:|:---------:|:-----:|:------:|:-----------:|
|
||||
| `/complaints/` - List | ✅ All | ✅ Hospital | ✅ Dept | ✅ Hospital | ❌ | ❌ | ✅ | **❌ (Redirected)** |
|
||||
| `/complaints/new/` - Create | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
| `/complaints/<id>/` - Detail | ✅ All | ✅ Hospital | ✅ Dept | ✅ Hospital | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
| `/complaints/<id>/assign/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `/complaints/<id>/change-status/` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `/complaints/<id>/activate/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `/complaints/<id>/escalate/` | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `/complaints/<id>/add-note/` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `/complaints/<id>/pdf/` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ |
|
||||
| `/complaints/<id>/request-explanation/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `/complaints/bulk/*` - Bulk Actions | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `/complaints/export/*` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `/complaints/analytics/` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ |
|
||||
| `/complaints/templates/` | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `/complaints/settings/sla/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `/complaints/settings/escalation-rules/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `/complaints/oncall/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `/complaints/adverse-actions/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
|
||||
**INQUIRIES (within Complaints):**
|
||||
| `/complaints/inquiries/` | ✅ All | ✅ Hospital | ✅ Dept | ✅ Hospital | ❌ | ❌ | ✅ | **❌ (Redirected)** |
|
||||
| `/complaints/inquiries/new/` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
| `/complaints/inquiries/<id>/` | ✅ All | ✅ Hospital | ✅ Dept | ✅ Hospital | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
| `/complaints/inquiries/<id>/activate/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `/complaints/inquiries/<id>/assign/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
|
||||
**SOURCE USER PORTAL (ONLY access for Source Users):**
|
||||
| `/px-sources/dashboard/` | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | **✅ ONLY** |
|
||||
| `/px-sources/complaints/` | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | **✅ ONLY** |
|
||||
| `/px-sources/inquiries/` | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | **✅ ONLY** |
|
||||
| `/px-sources/complaints/new/` | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | **✅ ONLY** |
|
||||
| `/px-sources/inquiries/new/` | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | **✅ ONLY** |
|
||||
|
||||
**Notes:**
|
||||
- **Source User**: **STRICT** - Can ONLY access via `/px-sources/*` portal. Main `/complaints/*` URLs redirect to source dashboard
|
||||
- **Department Manager**: Can only see complaints for their department
|
||||
- **Viewer**: Can view but not create/edit
|
||||
|
||||
---
|
||||
|
||||
### 3. SURVEYS MODULE
|
||||
|
||||
| Page/Feature | PX Admin | Hospital Admin | Dept Manager | PX Coordinator | Physician | Staff | Viewer | Source User |
|
||||
|-------------|:--------:|:--------------:|:------------:|:--------------:|:---------:|:-----:|:------:|:-----------:|
|
||||
| `/surveys/instances/` - Survey List | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | **❌ (Redirected)** |
|
||||
| `/surveys/instances/<id>/` - Detail | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | **❌ (Redirected)** |
|
||||
| `/surveys/templates/` - Templates | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
| `/surveys/templates/create/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
| `/surveys/send/` - Manual Send | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
| `/surveys/send/phone/` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
| `/surveys/send/csv/` | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
| `/surveys/his-import/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
| `/surveys/bulk-jobs/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
| `/surveys/reports/` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | **❌ (Redirected)** |
|
||||
| `/surveys/enhanced-reports/` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | **❌ (Redirected)** |
|
||||
| `/surveys/comments/` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
|
||||
**Notes:**
|
||||
- **Source User**: **NO ACCESS** - All survey URLs redirect to `/px-sources/dashboard/`
|
||||
- **Department Manager**: Can view surveys for their department
|
||||
- **Physician**: Can view their own ratings/surveys only
|
||||
|
||||
---
|
||||
|
||||
### 4. PX ACTION CENTER
|
||||
|
||||
| Page/Feature | PX Admin | Hospital Admin | Dept Manager | PX Coordinator | Physician | Staff | Viewer | Source User |
|
||||
|-------------|:--------:|:--------------:|:------------:|:--------------:|:---------:|:-----:|:------:|:-----------:|
|
||||
| `/actions/` - Action List | ✅ All | ✅ Hospital | ✅ Dept | ✅ Hospital | ❌ | ❌ | ✅ | ❌ |
|
||||
| `/actions/<id>/` - Detail | ✅ All | ✅ Hospital | ✅ Dept | ✅ Hospital | ❌ | ❌ | ✅ | ❌ |
|
||||
| `/actions/create/` | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `/actions/<id>/edit/` | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `/actions/<id>/assign/` | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `/actions/<id>/approve/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
|
||||
**Notes:**
|
||||
- **Source User**: NO ACCESS to PX Actions
|
||||
- **PX Coordinator**: Full access to manage actions
|
||||
|
||||
---
|
||||
|
||||
### 5. STAFF & ORGANIZATIONS
|
||||
|
||||
| Page/Feature | PX Admin | Hospital Admin | Dept Manager | PX Coordinator | Physician | Staff | Viewer | Source User |
|
||||
|-------------|:--------:|:--------------:|:------------:|:--------------:|:---------:|:-----:|:------:|:-----------:|
|
||||
| `/organizations/` - Organizations | ✅ | ✅ Own Org | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `/organizations/hospitals/` | ✅ | ✅ Own | ❌ | ❌ | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
| `/organizations/departments/` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | **❌ (Redirected)** |
|
||||
| `/organizations/staff/` - Staff List | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
| `/organizations/staff/<id>/` - Detail | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
| `/organizations/staff/create/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
| `/organizations/staff/<id>/edit/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
| `/organizations/staff/hierarchy/` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
| `/organizations/sections/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
| `/organizations/subsections/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
| `/organizations/patients/` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
|
||||
**Notes:**
|
||||
- **Source User**: **NO ACCESS** - All organization URLs redirect to `/px-sources/dashboard/`
|
||||
- **Department Manager**: Can view staff in their department
|
||||
- **Hospital Admin**: Full access within their hospital
|
||||
|
||||
---
|
||||
|
||||
### 6. PHYSICIANS MODULE
|
||||
|
||||
| Page/Feature | PX Admin | Hospital Admin | Dept Manager | PX Coordinator | Physician | Staff | Viewer | Source User |
|
||||
|-------------|:--------:|:--------------:|:------------:|:--------------:|:---------:|:-----:|:------:|:-----------:|
|
||||
| `/physicians/` - Physician List | ✅ | ✅ | ✅ | ✅ | ✅ Own | ✅ | ✅ | **❌ (Redirected)** |
|
||||
| `/physicians/<id>/` - Detail | ✅ | ✅ | ✅ | ✅ | ✅ Own | ❌ | ❌ | **❌ (Redirected)** |
|
||||
| `/physicians/dashboard/` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **❌ (Redirected)** |
|
||||
| `/physicians/leaderboard/` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **❌ (Redirected)** |
|
||||
| `/physicians/import/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
| `/physicians/individual-ratings/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
|
||||
**Notes:**
|
||||
- **Source User**: NO ACCESS
|
||||
- **Physician**: Can view their own ratings and profile
|
||||
|
||||
---
|
||||
|
||||
### 7. PX SOURCES MODULE
|
||||
|
||||
| Page/Feature | PX Admin | Hospital Admin | Dept Manager | PX Coordinator | Physician | Staff | Viewer | Source User |
|
||||
|-------------|:--------:|:--------------:|:------------:|:--------------:|:---------:|:-----:|:------:|:-----------:|
|
||||
| `/px-sources/` - Source List | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `/px-sources/<id>/` - Source Detail | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `/px-sources/<id>/users/create/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `/px-sources/dashboard/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ ✅ |
|
||||
| `/px-sources/complaints/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ ✅ |
|
||||
| `/px-sources/inquiries/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ ✅ |
|
||||
| `/px-sources/complaints/new/` | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ ✅ |
|
||||
| `/px-sources/inquiries/new/` | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ ✅ |
|
||||
|
||||
**Notes:**
|
||||
- **Source User**: Has their OWN simplified dashboard
|
||||
- **Source User**: Can only create complaints/inquiries from their assigned source
|
||||
- **Admin**: Can manage sources and create source users
|
||||
|
||||
---
|
||||
|
||||
### 8. SETTINGS & CONFIGURATION
|
||||
|
||||
| Page/Feature | PX Admin | Hospital Admin | Dept Manager | PX Coordinator | Physician | Staff | Viewer | Source User |
|
||||
|-------------|:--------:|:--------------:|:------------:|:--------------:|:---------:|:-----:|:------:|:-----------:|
|
||||
| `/config/dashboard/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
| `/config/routing-rules/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
| `/config/sla-config/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
| `/integrations/survey-mapping-settings/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
| `/notifications/send-sms-direct/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | **❌ (Redirected)** |
|
||||
| `/notifications/settings/` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | **❌ (Redirected)** |
|
||||
| `/accounts/password/change/` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **✅ ONLY** |
|
||||
| `/accounts/settings/` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **✅ ONLY** |
|
||||
|
||||
**Notes:**
|
||||
- **Source User**: **ONLY** allowed Settings pages are password change and basic settings
|
||||
- All other config pages redirect to `/px-sources/dashboard/`
|
||||
- **Source User**: NO ACCESS to any settings
|
||||
- **Hospital Admin**: Can configure hospital-specific settings
|
||||
|
||||
---
|
||||
|
||||
### 9. ACKNOWLEDGEMENTS (Onboarding)
|
||||
|
||||
| Page/Feature | PX Admin | Hospital Admin | Dept Manager | PX Coordinator | Physician | Staff | Viewer | Source User |
|
||||
|-------------|:--------:|:--------------:|:------------:|:--------------:|:---------:|:-----:|:------:|:-----------:|
|
||||
| `/acknowledgements/dashboard/` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| `/acknowledgements/signed/` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| `/acknowledgements/sign/<id>/` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| `/acknowledgements/categories/` | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `/acknowledgements/checklist/` | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `/acknowledgements/compliance/` | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
|
||||
**Notes:**
|
||||
- **Source User**: NO ACCESS to acknowledgements
|
||||
- **Admin**: Can manage acknowledgement content
|
||||
|
||||
---
|
||||
|
||||
### 10. USER ACCOUNT & PROFILE
|
||||
|
||||
| Page/Feature | PX Admin | Hospital Admin | Dept Manager | PX Coordinator | Physician | Staff | Viewer | Source User |
|
||||
|-------------|:--------:|:--------------:|:------------:|:--------------:|:---------:|:-----:|:------:|:-----------:|
|
||||
| `/accounts/settings/` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| `/accounts/change-password/` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| `/accounts/users/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `/accounts/users/<id>/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `/accounts/roles/` | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `/accounts/onboarding/provisional/` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `/accounts/onboarding/wizard/` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
|
||||
**Notes:**
|
||||
- **All users** can access their own settings and change password
|
||||
- **Source User**: Can only view/edit their own profile
|
||||
|
||||
---
|
||||
|
||||
## 🚫 Access Denied Behavior
|
||||
|
||||
When a user tries to access a page they don't have permission for:
|
||||
|
||||
1. **API Endpoints**: Returns HTTP 403 Forbidden with error message
|
||||
2. **UI Views**: Redirects to login or shows permission denied page
|
||||
3. **Menu Items**: Hidden from sidebar (not shown)
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Role Permission Summary
|
||||
|
||||
### PX Admin (Level 100)
|
||||
- ✅ Full system access
|
||||
- ✅ Can switch between all hospitals
|
||||
- ✅ Can create/edit/delete users
|
||||
- ✅ Can access all settings
|
||||
- ✅ Can view all reports and analytics
|
||||
|
||||
### Hospital Admin (Level 80)
|
||||
- ✅ Full access within their hospital
|
||||
- ✅ Can manage staff in their hospital
|
||||
- ✅ Can manage complaints/inquiries in their hospital
|
||||
- ✅ Can configure hospital settings
|
||||
- ❌ Cannot access other hospitals
|
||||
|
||||
### Department Manager (Level 60)
|
||||
- ✅ Access to their department only
|
||||
- ✅ Can view staff in their department
|
||||
- ✅ Can manage complaints in their department
|
||||
- ✅ Can view department reports
|
||||
- ❌ Cannot access other departments
|
||||
|
||||
### PX Coordinator (Level 50)
|
||||
- ✅ Can create and manage complaints
|
||||
- ✅ Can create and manage PX Actions
|
||||
- ✅ Can manage surveys
|
||||
- ✅ Can view analytics
|
||||
- ❌ Cannot manage staff or settings
|
||||
|
||||
### Physician (Level 40)
|
||||
- ✅ Can view their own ratings
|
||||
- ✅ Can view patient feedback
|
||||
- ❌ Cannot create complaints
|
||||
- ❌ Cannot access admin functions
|
||||
|
||||
### Nurse/Staff (Level 30/20)
|
||||
- ✅ Can view department data
|
||||
- ✅ Basic read access
|
||||
- ❌ Limited write access
|
||||
|
||||
### Viewer (Level 10)
|
||||
- ✅ Read-only access
|
||||
- ✅ Can view reports and dashboards
|
||||
- ❌ Cannot create or edit anything
|
||||
|
||||
### PX Source User (Level 5) - **STRICT ACCESS**
|
||||
- ✅ Can create complaints from their source (via `/px-sources/complaints/new/`)
|
||||
- ✅ Can create inquiries from their source (via `/px-sources/inquiries/new/`)
|
||||
- ✅ Can view only their created complaints/inquiries (via `/px-sources/`)
|
||||
- ✅ Can change password (`/accounts/password/change/`)
|
||||
- ✅ Can access basic settings (`/accounts/settings/`)
|
||||
- ❌ **NO access** to `/` (Command Center) - **Redirected**
|
||||
- ❌ **NO access** to `/dashboard/my/` - **Redirected**
|
||||
- ❌ **NO access** to `/complaints/` (main) - **Redirected**
|
||||
- ❌ **NO access** to surveys - **Redirected**
|
||||
- ❌ **NO access** to staff/organizations - **Redirected**
|
||||
- ❌ **NO access** to settings/config - **Redirected**
|
||||
- ❌ **NO access** to PX Actions - **Redirected**
|
||||
- ❌ **NO access** to analytics - **Redirected**
|
||||
- ❌ **NO access** to acknowledgements - **Redirected**
|
||||
|
||||
**ENFORCED BY MIDDLEWARE**: `SourceUserRestrictionMiddleware` ensures strict access control. Any attempt to access non-allowed URLs automatically redirects to `/px-sources/dashboard/`.
|
||||
|
||||
---
|
||||
|
||||
## 📝 Last Updated
|
||||
|
||||
- **Date**: 2026-02-25
|
||||
- **Version**: 1.0
|
||||
- **Changes**: Added PX Source User role documentation
|
||||
@ -1,55 +1,24 @@
|
||||
"""
|
||||
Context processors for accounts app
|
||||
Context processor for acknowledgement notifications
|
||||
"""
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
from apps.accounts.models import EmployeeAcknowledgement
|
||||
|
||||
|
||||
def acknowledgement_counts(request):
|
||||
"""
|
||||
Context processor to add acknowledgement-related counts to all templates
|
||||
Add pending acknowledgement count to all templates
|
||||
"""
|
||||
context = {}
|
||||
|
||||
# Only process for authenticated users
|
||||
if request.user.is_authenticated:
|
||||
# Count pending acknowledgements for the current user
|
||||
from .models import UserAcknowledgement, AcknowledgementChecklistItem
|
||||
|
||||
# Get checklist items for the user's role
|
||||
checklist_items = AcknowledgementChecklistItem.objects.filter(is_active=True)
|
||||
|
||||
# Filter by user's role if applicable
|
||||
user_role = None
|
||||
if request.user.groups.filter(name='PX Admin').exists():
|
||||
user_role = 'px_admin'
|
||||
elif request.user.groups.filter(name='Hospital Admin').exists():
|
||||
user_role = 'hospital_admin'
|
||||
elif request.user.groups.filter(name='Department Manager').exists():
|
||||
user_role = 'department_manager'
|
||||
elif request.user.groups.filter(name='Staff').exists():
|
||||
user_role = 'staff'
|
||||
elif request.user.groups.filter(name='Physician').exists():
|
||||
user_role = 'physician'
|
||||
|
||||
if user_role:
|
||||
checklist_items = checklist_items.filter(role__in=[user_role, 'all'])
|
||||
else:
|
||||
checklist_items = checklist_items.filter(role='all')
|
||||
|
||||
# Count pending acknowledgements (not yet signed)
|
||||
acknowledged_ids = UserAcknowledgement.objects.filter(
|
||||
user=request.user,
|
||||
is_acknowledged=True
|
||||
).values_list('checklist_item_id', flat=True)
|
||||
|
||||
pending_count = checklist_items.exclude(id__in=acknowledged_ids).count()
|
||||
context['pending_acknowledgements_count'] = pending_count
|
||||
|
||||
# For PX Admins, count provisional users
|
||||
if request.user.is_px_admin():
|
||||
provisional_count = User.objects.filter(is_provisional=True).count()
|
||||
context['provisional_user_count'] = provisional_count
|
||||
|
||||
return context
|
||||
if not request.user.is_authenticated:
|
||||
return {}
|
||||
|
||||
try:
|
||||
pending_count = EmployeeAcknowledgement.objects.filter(
|
||||
employee=request.user,
|
||||
is_signed=False
|
||||
).count()
|
||||
except:
|
||||
pending_count = 0
|
||||
|
||||
return {
|
||||
'pending_acknowledgements_count': pending_count,
|
||||
}
|
||||
|
||||
@ -63,6 +63,12 @@ class Command(BaseCommand):
|
||||
'description': 'Read-only access to reports and dashboards.',
|
||||
'level': 10,
|
||||
},
|
||||
{
|
||||
'name': 'px_source_user',
|
||||
'display_name': 'PX Source User',
|
||||
'description': 'External source users who can create complaints and inquiries from their assigned source. Limited access to their own created data only.',
|
||||
'level': 5,
|
||||
},
|
||||
]
|
||||
|
||||
created_count = 0
|
||||
@ -163,6 +169,16 @@ class Command(BaseCommand):
|
||||
group.permissions.set(coordinator_permissions)
|
||||
return
|
||||
|
||||
# PX Source User gets limited complaint/inquiry permissions
|
||||
if role.name == 'px_source_user':
|
||||
source_user_perms = Permission.objects.filter(
|
||||
content_type__app_label__in=['complaints'],
|
||||
codename__in=['add_complaint', 'view_complaint', 'change_complaint',
|
||||
'add_inquiry', 'view_inquiry', 'change_inquiry']
|
||||
)
|
||||
group.permissions.set(source_user_perms)
|
||||
return
|
||||
|
||||
# Others get basic view permissions
|
||||
view_permissions = Permission.objects.filter(
|
||||
codename__startswith='view_'
|
||||
|
||||
152
apps/accounts/management/commands/seed_acknowledgements.py
Normal file
152
apps/accounts/management/commands/seed_acknowledgements.py
Normal file
@ -0,0 +1,152 @@
|
||||
"""
|
||||
Management command to seed the 14 default acknowledgement categories.
|
||||
|
||||
Usage:
|
||||
python manage.py seed_acknowledgements
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Seed the 14 default acknowledgement categories'
|
||||
|
||||
# 14 default categories as specified
|
||||
DEFAULT_CATEGORIES = [
|
||||
{
|
||||
'code': 'CLINICS',
|
||||
'name_en': 'Clinics',
|
||||
'name_ar': 'العيادات',
|
||||
'icon': 'building',
|
||||
'color': '#007bbd',
|
||||
},
|
||||
{
|
||||
'code': 'ADMISSIONS_SOCIAL',
|
||||
'name_en': 'Admissions / Social Services',
|
||||
'name_ar': 'القبول / الخدمات الاجتماعية',
|
||||
'icon': 'users',
|
||||
'color': '#005696',
|
||||
},
|
||||
{
|
||||
'code': 'MED_APPROVALS',
|
||||
'name_en': 'Medical Approvals',
|
||||
'name_ar': 'الموافقات الطبية',
|
||||
'icon': 'file-check',
|
||||
'color': '#10b981',
|
||||
},
|
||||
{
|
||||
'code': 'CALL_CENTER',
|
||||
'name_en': 'Call Center',
|
||||
'name_ar': 'مركز الاتصال',
|
||||
'icon': 'phone',
|
||||
'color': '#f59e0b',
|
||||
},
|
||||
{
|
||||
'code': 'PAYMENTS',
|
||||
'name_en': 'Payments',
|
||||
'name_ar': 'المدفوعات',
|
||||
'icon': 'credit-card',
|
||||
'color': '#6366f1',
|
||||
},
|
||||
{
|
||||
'code': 'EMERGENCY',
|
||||
'name_en': 'Emergency Services',
|
||||
'name_ar': 'خدمات الطوارئ',
|
||||
'icon': 'alert-circle',
|
||||
'color': '#ef4444',
|
||||
},
|
||||
{
|
||||
'code': 'MED_REPORTS',
|
||||
'name_en': 'Medical Reports',
|
||||
'name_ar': 'التقارير الطبية',
|
||||
'icon': 'file-text',
|
||||
'color': '#8b5cf6',
|
||||
},
|
||||
{
|
||||
'code': 'ADMISSIONS_OFFICE',
|
||||
'name_en': 'Admissions Office',
|
||||
'name_ar': 'مكتب القبول',
|
||||
'icon': 'briefcase',
|
||||
'color': '#06b6d4',
|
||||
},
|
||||
{
|
||||
'code': 'CBAHI',
|
||||
'name_en': 'CBAHI',
|
||||
'name_ar': 'الهلال الأحمر',
|
||||
'icon': 'shield',
|
||||
'color': '#dc2626',
|
||||
},
|
||||
{
|
||||
'code': 'HR_PORTAL',
|
||||
'name_en': 'HR Portal',
|
||||
'name_ar': 'بوابة الموارد البشرية',
|
||||
'icon': 'user-cog',
|
||||
'color': '#059669',
|
||||
},
|
||||
{
|
||||
'code': 'GENERAL_ORIENTATION',
|
||||
'name_en': 'General Orientation',
|
||||
'name_ar': 'التوجيه العام',
|
||||
'icon': 'info',
|
||||
'color': '#0891b2',
|
||||
},
|
||||
{
|
||||
'code': 'SEHATY',
|
||||
'name_en': 'Sehaty App (sick leaves)',
|
||||
'name_ar': 'تطبيق صحتي (إجازات مرضية)',
|
||||
'icon': 'smartphone',
|
||||
'color': '#7c3aed',
|
||||
},
|
||||
{
|
||||
'code': 'MOH_CARE',
|
||||
'name_en': 'MOH Care Portal',
|
||||
'name_ar': 'بوابة رعاية وزارة الصحة',
|
||||
'icon': 'heart-pulse',
|
||||
'color': '#db2777',
|
||||
},
|
||||
{
|
||||
'code': 'CHI_CARE',
|
||||
'name_en': 'CHI Care Portal',
|
||||
'name_ar': 'بوابة رعاية مجلس الصحة',
|
||||
'icon': 'activity',
|
||||
'color': '#0284c7',
|
||||
},
|
||||
]
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from apps.accounts.models import AcknowledgementCategory
|
||||
|
||||
self.stdout.write(self.style.WARNING('Seeding 14 default acknowledgement categories...'))
|
||||
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
|
||||
for category_data in self.DEFAULT_CATEGORIES:
|
||||
category, created = AcknowledgementCategory.objects.update_or_create(
|
||||
code=category_data['code'],
|
||||
defaults={
|
||||
'name_en': category_data['name_en'],
|
||||
'name_ar': category_data['name_ar'],
|
||||
'icon': category_data['icon'],
|
||||
'color': category_data['color'],
|
||||
'is_default': True,
|
||||
'is_active': True,
|
||||
}
|
||||
)
|
||||
|
||||
if created:
|
||||
created_count += 1
|
||||
self.stdout.write(self.style.SUCCESS(f'✓ Created: {category.name_en}'))
|
||||
else:
|
||||
updated_count += 1
|
||||
self.stdout.write(self.style.WARNING(f'↻ Updated: {category.name_en}'))
|
||||
|
||||
# Summary
|
||||
total = AcknowledgementCategory.objects.filter(is_default=True).count()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('\n' + '='*50))
|
||||
self.stdout.write(self.style.SUCCESS(f'✓ Seeding complete!'))
|
||||
self.stdout.write(self.style.SUCCESS(f' Created: {created_count}'))
|
||||
self.stdout.write(self.style.SUCCESS(f' Updated: {updated_count}'))
|
||||
self.stdout.write(self.style.SUCCESS(f' Total default categories: {total}'))
|
||||
self.stdout.write(self.style.SUCCESS('='*50))
|
||||
@ -198,6 +198,18 @@ class User(AbstractUser, TimeStampedModel):
|
||||
"""Check if user is Department Manager"""
|
||||
return self.has_role('Department Manager')
|
||||
|
||||
def is_source_user(self):
|
||||
"""Check if user is a PX Source User"""
|
||||
return self.has_role('PX Source User')
|
||||
|
||||
def get_source_user_profile_active(self):
|
||||
"""Get active source user profile if exists"""
|
||||
if hasattr(self, 'source_user_profile'):
|
||||
profile = self.source_user_profile
|
||||
if profile and profile.is_active:
|
||||
return profile
|
||||
return None
|
||||
|
||||
def needs_onboarding(self):
|
||||
"""Check if user needs to complete onboarding"""
|
||||
return self.is_provisional and not self.acknowledgement_completed
|
||||
@ -208,29 +220,74 @@ class User(AbstractUser, TimeStampedModel):
|
||||
return OnboardingService.get_user_progress_percentage(self)
|
||||
|
||||
|
||||
class AcknowledgementCategory(UUIDModel, TimeStampedModel):
|
||||
"""
|
||||
Categories/Departments for acknowledgements.
|
||||
Admins can add, edit, or deactivate categories.
|
||||
Replaces hardcoded department choices with database-driven categories.
|
||||
"""
|
||||
name_en = models.CharField(max_length=200)
|
||||
name_ar = models.CharField(max_length=200, blank=True, help_text="Arabic name")
|
||||
code = models.CharField(
|
||||
max_length=50,
|
||||
unique=True,
|
||||
db_index=True,
|
||||
help_text="Unique code (e.g., CLINICS, ADMISSIONS)"
|
||||
)
|
||||
description = models.TextField(blank=True, help_text="Category description")
|
||||
|
||||
# Display & ordering
|
||||
icon = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
help_text="Icon class (e.g., 'building', 'user')"
|
||||
)
|
||||
color = models.CharField(
|
||||
max_length=7,
|
||||
default='#007bbd',
|
||||
help_text="Hex color code"
|
||||
)
|
||||
order = models.IntegerField(default=0, help_text="Display order")
|
||||
|
||||
# Status
|
||||
is_active = models.BooleanField(default=True, db_index=True)
|
||||
is_default = models.BooleanField(
|
||||
default=False,
|
||||
help_text="One of the 14 default categories"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['order', 'name_en']
|
||||
verbose_name_plural = 'Acknowledgement Categories'
|
||||
indexes = [
|
||||
models.Index(fields=['is_active', 'order']),
|
||||
models.Index(fields=['code']),
|
||||
models.Index(fields=['is_default']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name_en
|
||||
|
||||
def activate(self):
|
||||
self.is_active = True
|
||||
self.save(update_fields=['is_active'])
|
||||
|
||||
def deactivate(self):
|
||||
self.is_active = False
|
||||
self.save(update_fields=['is_active'])
|
||||
|
||||
|
||||
class AcknowledgementContent(UUIDModel, TimeStampedModel):
|
||||
"""
|
||||
Acknowledgement content sections for onboarding wizard.
|
||||
Provides bilingual, role-specific educational content.
|
||||
Acknowledgement content sections.
|
||||
Provides bilingual educational content for each acknowledgement category.
|
||||
"""
|
||||
ROLE_CHOICES = [
|
||||
('px_admin', 'PX Admin'),
|
||||
('hospital_admin', 'Hospital Admin'),
|
||||
('department_manager', 'Department Manager'),
|
||||
('px_coordinator', 'PX Coordinator'),
|
||||
('physician', 'Physician'),
|
||||
('nurse', 'Nurse'),
|
||||
('staff', 'Staff'),
|
||||
('viewer', 'Viewer'),
|
||||
]
|
||||
|
||||
# Target role (leave blank for all roles)
|
||||
role = models.CharField(
|
||||
max_length=50,
|
||||
choices=ROLE_CHOICES,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Target role for this content"
|
||||
# Link to category (replaces role field)
|
||||
category = models.ForeignKey(
|
||||
AcknowledgementCategory,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='content_sections',
|
||||
help_text="Category this content belongs to"
|
||||
)
|
||||
|
||||
# Content section
|
||||
@ -263,47 +320,34 @@ class AcknowledgementContent(UUIDModel, TimeStampedModel):
|
||||
# Organization
|
||||
order = models.IntegerField(
|
||||
default=0,
|
||||
help_text="Display order in wizard"
|
||||
help_text="Display order"
|
||||
)
|
||||
|
||||
# Status
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['role', 'order', 'code']
|
||||
ordering = ['category', 'order', 'code']
|
||||
indexes = [
|
||||
models.Index(fields=['role', 'is_active', 'order']),
|
||||
models.Index(fields=['category', 'is_active', 'order']),
|
||||
models.Index(fields=['code']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
role_text = self.get_role_display() if self.role else "All Roles"
|
||||
return f"{role_text} - {self.title_en}"
|
||||
return f"{self.category.name_en} - {self.title_en}"
|
||||
|
||||
|
||||
class AcknowledgementChecklistItem(UUIDModel, TimeStampedModel):
|
||||
"""
|
||||
Checklist items that users must acknowledge during onboarding.
|
||||
Can be role-specific and linked to content sections.
|
||||
Checklist items that users must acknowledge.
|
||||
Each item is linked to a category (department).
|
||||
"""
|
||||
ROLE_CHOICES = [
|
||||
('px_admin', 'PX Admin'),
|
||||
('hospital_admin', 'Hospital Admin'),
|
||||
('department_manager', 'Department Manager'),
|
||||
('px_coordinator', 'PX Coordinator'),
|
||||
('physician', 'Physician'),
|
||||
('nurse', 'Nurse'),
|
||||
('staff', 'Staff'),
|
||||
('viewer', 'Viewer'),
|
||||
]
|
||||
|
||||
# Target role (leave blank for all roles)
|
||||
role = models.CharField(
|
||||
max_length=50,
|
||||
choices=ROLE_CHOICES,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Target role for this item"
|
||||
# Link to category (replaces role field)
|
||||
category = models.ForeignKey(
|
||||
AcknowledgementCategory,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='checklist_items',
|
||||
help_text="Category/Department this acknowledgement belongs to"
|
||||
)
|
||||
|
||||
# Linked content (optional)
|
||||
@ -341,15 +385,14 @@ class AcknowledgementChecklistItem(UUIDModel, TimeStampedModel):
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['role', 'order', 'code']
|
||||
ordering = ['category', 'order', 'code']
|
||||
indexes = [
|
||||
models.Index(fields=['role', 'is_active', 'order']),
|
||||
models.Index(fields=['category', 'is_active', 'order']),
|
||||
models.Index(fields=['code']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
role_text = self.get_role_display() if self.role else "All Roles"
|
||||
return f"{role_text} - {self.text_en}"
|
||||
return f"{self.category.name_en} - {self.text_en}"
|
||||
|
||||
|
||||
class UserAcknowledgement(UUIDModel, TimeStampedModel):
|
||||
@ -511,3 +554,104 @@ class Role(models.Model):
|
||||
|
||||
# Import version models to ensure they are registered with Django
|
||||
from .version_models import ContentVersion, ChecklistItemVersion, ContentChangeLog
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SIMPLE ACKNOWLEDGEMENT MODELS
|
||||
# ============================================================================
|
||||
|
||||
class SimpleAcknowledgement(UUIDModel, TimeStampedModel):
|
||||
"""
|
||||
Simple acknowledgement item that employees must sign.
|
||||
"""
|
||||
title = models.CharField(max_length=200, help_text="Acknowledgement title")
|
||||
description = models.TextField(blank=True, help_text="Detailed description")
|
||||
pdf_document = models.FileField(
|
||||
upload_to='acknowledgements/documents/',
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="PDF document for employees to review"
|
||||
)
|
||||
|
||||
# Status
|
||||
is_active = models.BooleanField(default=True, help_text="Show in employee checklist")
|
||||
is_required = models.BooleanField(default=True, help_text="Must be signed by all employees")
|
||||
|
||||
# Display order
|
||||
order = models.IntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ['order', 'title']
|
||||
verbose_name = "Acknowledgement"
|
||||
verbose_name_plural = "Acknowledgements"
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
@property
|
||||
def signed_count(self):
|
||||
"""Number of employees who have signed this acknowledgement."""
|
||||
return self.employee_signatures.filter(is_signed=True).count()
|
||||
|
||||
|
||||
class EmployeeAcknowledgement(UUIDModel, TimeStampedModel):
|
||||
"""
|
||||
Records which employees have signed which acknowledgements.
|
||||
"""
|
||||
employee = models.ForeignKey(
|
||||
'accounts.User',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='employee_acknowledgements',
|
||||
help_text="Employee who signed"
|
||||
)
|
||||
acknowledgement = models.ForeignKey(
|
||||
SimpleAcknowledgement,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='employee_signatures',
|
||||
help_text="Acknowledgement that was signed"
|
||||
)
|
||||
|
||||
# Sent tracking
|
||||
sent_at = models.DateTimeField(null=True, blank=True, help_text="When acknowledgement was sent to employee")
|
||||
sent_by = models.ForeignKey(
|
||||
'accounts.User',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='sent_acknowledgements',
|
||||
help_text="Admin who sent this acknowledgement"
|
||||
)
|
||||
|
||||
# Signature details
|
||||
is_signed = models.BooleanField(default=False)
|
||||
signed_at = models.DateTimeField(null=True, blank=True)
|
||||
signature_name = models.CharField(max_length=200, blank=True, help_text="Name used when signing")
|
||||
signature_employee_id = models.CharField(max_length=100, blank=True, help_text="Employee ID when signing")
|
||||
|
||||
# Signed PDF
|
||||
signed_pdf = models.FileField(
|
||||
upload_to='acknowledgements/signed/%Y/%m/',
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="PDF with employee signature"
|
||||
)
|
||||
|
||||
# IP and user agent for audit
|
||||
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||
user_agent = models.TextField(blank=True)
|
||||
|
||||
# Notes
|
||||
notes = models.TextField(blank=True, help_text="Admin notes about this acknowledgement")
|
||||
|
||||
class Meta:
|
||||
ordering = ['-sent_at', '-signed_at']
|
||||
unique_together = [['employee', 'acknowledgement']]
|
||||
verbose_name = "Employee Acknowledgement"
|
||||
verbose_name_plural = "Employee Acknowledgements"
|
||||
|
||||
def __str__(self):
|
||||
if self.is_signed:
|
||||
return f"✓ {self.employee.get_full_name()} - {self.acknowledgement.title}"
|
||||
elif self.sent_at:
|
||||
return f"→ {self.employee.get_full_name()} - {self.acknowledgement.title} (Sent)"
|
||||
return f"○ {self.employee.get_full_name()} - {self.acknowledgement.title}"
|
||||
|
||||
@ -173,6 +173,69 @@ class CanAccessDepartmentData(permissions.BasePermission):
|
||||
return False
|
||||
|
||||
|
||||
class IsSourceUser(permissions.BasePermission):
|
||||
"""
|
||||
Permission class to check if user is a PX Source User.
|
||||
Source Users can only access data from their assigned source.
|
||||
"""
|
||||
message = "You must be a Source User to perform this action."
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return (
|
||||
request.user and
|
||||
request.user.is_authenticated and
|
||||
request.user.is_source_user()
|
||||
)
|
||||
|
||||
|
||||
class IsSourceUserOrAdmin(permissions.BasePermission):
|
||||
"""
|
||||
Permission class for Source Users or Admins.
|
||||
Allows access to:
|
||||
- PX Admins
|
||||
- Hospital Admins
|
||||
- Department Managers
|
||||
- Active Source Users
|
||||
"""
|
||||
message = "You must be a Source User or Admin to perform this action."
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not (request.user and request.user.is_authenticated):
|
||||
return False
|
||||
|
||||
# Admins have access
|
||||
if (request.user.is_px_admin() or
|
||||
request.user.is_hospital_admin() or
|
||||
request.user.is_department_manager()):
|
||||
return True
|
||||
|
||||
# Source Users have access
|
||||
return request.user.is_source_user()
|
||||
|
||||
|
||||
class IsSourceUserOwnData(permissions.BasePermission):
|
||||
"""
|
||||
Object-level permission to ensure source users can only access their own data.
|
||||
"""
|
||||
message = "You can only access data you created."
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if not (request.user and request.user.is_authenticated):
|
||||
return False
|
||||
|
||||
# Admins can access everything
|
||||
if (request.user.is_px_admin() or
|
||||
request.user.is_hospital_admin() or
|
||||
request.user.is_department_manager()):
|
||||
return True
|
||||
|
||||
# Source Users can only access their own created data
|
||||
if request.user.is_source_user():
|
||||
return getattr(obj, 'created_by', None) == request.user
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# ==================== Onboarding Permissions ====================
|
||||
|
||||
class IsProvisionalUser(permissions.BasePermission):
|
||||
|
||||
43
apps/accounts/simple_acknowledgement_urls.py
Normal file
43
apps/accounts/simple_acknowledgement_urls.py
Normal file
@ -0,0 +1,43 @@
|
||||
"""
|
||||
Simple Acknowledgement URLs
|
||||
"""
|
||||
from django.urls import path
|
||||
from .simple_acknowledgement_views import (
|
||||
simple_acknowledgement_list,
|
||||
simple_acknowledgement_sign,
|
||||
simple_acknowledgement_download,
|
||||
admin_acknowledgement_list,
|
||||
admin_acknowledgement_create,
|
||||
admin_acknowledgement_edit,
|
||||
admin_acknowledgement_delete,
|
||||
admin_send_acknowledgement,
|
||||
admin_upload_signed_pdf,
|
||||
admin_mark_as_sent,
|
||||
admin_employee_signatures,
|
||||
admin_export_signatures,
|
||||
)
|
||||
|
||||
app_name = 'simple_acknowledgements'
|
||||
|
||||
urlpatterns = [
|
||||
# Employee URLs
|
||||
path('my-acknowledgements/', simple_acknowledgement_list, name='employee_list'),
|
||||
path('my-acknowledgements/sign/<uuid:ack_id>/', simple_acknowledgement_sign, name='sign'),
|
||||
path('my-acknowledgements/download/<uuid:ack_id>/', simple_acknowledgement_download, name='download_pdf'),
|
||||
|
||||
# Admin URLs
|
||||
path('admin/acknowledgements/', admin_acknowledgement_list, name='admin_list'),
|
||||
path('admin/acknowledgements/new/', admin_acknowledgement_create, name='admin_create'),
|
||||
path('admin/acknowledgements/<uuid:ack_id>/edit/', admin_acknowledgement_edit, name='admin_edit'),
|
||||
path('admin/acknowledgements/<uuid:ack_id>/delete/', admin_acknowledgement_delete, name='admin_delete'),
|
||||
path('admin/acknowledgements/<uuid:ack_id>/send/', admin_send_acknowledgement, name='admin_send'),
|
||||
path('admin/acknowledgements/<uuid:ack_id>/send-all/', admin_mark_as_sent, name='admin_send_all'),
|
||||
|
||||
# Admin Upload Signed PDF
|
||||
path('admin/signatures/<uuid:signature_id>/upload/', admin_upload_signed_pdf, name='admin_upload_pdf'),
|
||||
|
||||
# Admin Signatures/Compliance
|
||||
path('admin/signatures/', admin_employee_signatures, name='admin_signatures'),
|
||||
path('admin/signatures/<uuid:ack_id>/', admin_employee_signatures, name='admin_ack_signatures'),
|
||||
path('admin/export/', admin_export_signatures, name='admin_export'),
|
||||
]
|
||||
565
apps/accounts/simple_acknowledgement_views.py
Normal file
565
apps/accounts/simple_acknowledgement_views.py
Normal file
@ -0,0 +1,565 @@
|
||||
"""
|
||||
Simple Acknowledgement Views
|
||||
Simplified views for employee acknowledgement system.
|
||||
"""
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils import timezone
|
||||
from django.http import HttpResponse
|
||||
from django.db.models import Count, Q
|
||||
from django.core.paginator import Paginator
|
||||
|
||||
from apps.accounts.models import User, SimpleAcknowledgement, EmployeeAcknowledgement
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# EMPLOYEE VIEWS
|
||||
# ============================================================================
|
||||
|
||||
@login_required
|
||||
def simple_acknowledgement_list(request):
|
||||
"""
|
||||
Employee view: List all acknowledgements with sign status.
|
||||
Shows acknowledgements that were sent to the employee OR are active and required.
|
||||
"""
|
||||
employee = request.user
|
||||
|
||||
# Get acknowledgements that were specifically sent to this employee
|
||||
sent_ack_ids = EmployeeAcknowledgement.objects.filter(
|
||||
employee=employee
|
||||
).values_list('acknowledgement_id', flat=True)
|
||||
|
||||
# Get all active required acknowledgements that haven't been sent yet
|
||||
active_required_ids = SimpleAcknowledgement.objects.filter(
|
||||
is_active=True,
|
||||
is_required=True
|
||||
).exclude(
|
||||
id__in=sent_ack_ids
|
||||
).values_list('id', flat=True)
|
||||
|
||||
# Combine all acknowledgement IDs the employee needs to see
|
||||
all_ack_ids = list(sent_ack_ids) + list(active_required_ids)
|
||||
|
||||
# Get all acknowledgements
|
||||
acknowledgements = SimpleAcknowledgement.objects.filter(
|
||||
id__in=all_ack_ids
|
||||
).order_by('order', 'title')
|
||||
|
||||
# Get or create employee acknowledgements for each
|
||||
employee_acks = []
|
||||
for ack in acknowledgements:
|
||||
emp_ack, created = EmployeeAcknowledgement.objects.get_or_create(
|
||||
employee=employee,
|
||||
acknowledgement=ack,
|
||||
defaults={'is_signed': False}
|
||||
)
|
||||
employee_acks.append({
|
||||
'acknowledgement': ack,
|
||||
'signature': emp_ack,
|
||||
'is_signed': emp_ack.is_signed,
|
||||
'signed_at': emp_ack.signed_at,
|
||||
'has_pdf': bool(emp_ack.signed_pdf),
|
||||
'sent_at': emp_ack.sent_at,
|
||||
'sent_by': emp_ack.sent_by,
|
||||
})
|
||||
|
||||
# Separate pending and signed
|
||||
pending_acks = [e for e in employee_acks if not e['is_signed']]
|
||||
signed_acks = [e for e in employee_acks if e['is_signed']]
|
||||
|
||||
# Calculate stats
|
||||
total = len(employee_acks)
|
||||
signed = len(signed_acks)
|
||||
pending = len(pending_acks)
|
||||
|
||||
context = {
|
||||
'employee_acks': employee_acks,
|
||||
'pending_acks': pending_acks,
|
||||
'signed_acks': signed_acks,
|
||||
'total': total,
|
||||
'signed': signed,
|
||||
'pending': pending,
|
||||
'progress': int((signed / total * 100)) if total > 0 else 0,
|
||||
}
|
||||
return render(request, 'accounts/simple_acknowledgements/list.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def simple_acknowledgement_sign(request, ack_id):
|
||||
"""
|
||||
Employee view: Sign an acknowledgement.
|
||||
Shows the acknowledgement details and a form to sign with name and employee ID.
|
||||
"""
|
||||
acknowledgement = get_object_or_404(SimpleAcknowledgement, id=ack_id, is_active=True)
|
||||
employee = request.user
|
||||
|
||||
# Get or create the employee acknowledgement record
|
||||
emp_ack, created = EmployeeAcknowledgement.objects.get_or_create(
|
||||
employee=employee,
|
||||
acknowledgement=acknowledgement,
|
||||
defaults={'is_signed': False}
|
||||
)
|
||||
|
||||
# If already signed, redirect to list
|
||||
if emp_ack.is_signed:
|
||||
messages.info(request, _('You have already signed this acknowledgement.'))
|
||||
return redirect('accounts:simple_acknowledgements:employee_list')
|
||||
|
||||
if request.method == 'POST':
|
||||
signature_name = request.POST.get('signature_name', '').strip()
|
||||
signature_employee_id = request.POST.get('signature_employee_id', '').strip()
|
||||
confirm = request.POST.get('confirm') == 'on'
|
||||
|
||||
# Validation
|
||||
if not signature_name:
|
||||
messages.error(request, _('Please enter your name.'))
|
||||
elif not signature_employee_id:
|
||||
messages.error(request, _('Please enter your employee ID.'))
|
||||
elif not confirm:
|
||||
messages.error(request, _('Please confirm that you have read and understood the acknowledgement.'))
|
||||
else:
|
||||
# Save the signature
|
||||
emp_ack.is_signed = True
|
||||
emp_ack.signed_at = timezone.now()
|
||||
emp_ack.signature_name = signature_name
|
||||
emp_ack.signature_employee_id = signature_employee_id
|
||||
emp_ack.ip_address = get_client_ip(request)
|
||||
emp_ack.user_agent = request.META.get('HTTP_USER_AGENT', '')[:500]
|
||||
emp_ack.save()
|
||||
|
||||
# Generate simple signed PDF (optional - can be added later)
|
||||
# For now, just copy the original PDF if it exists
|
||||
if acknowledgement.pdf_document and not emp_ack.signed_pdf:
|
||||
emp_ack.signed_pdf = acknowledgement.pdf_document
|
||||
emp_ack.save()
|
||||
|
||||
messages.success(request, _('Acknowledgement signed successfully!'))
|
||||
return redirect('accounts:simple_acknowledgements:employee_list')
|
||||
|
||||
context = {
|
||||
'acknowledgement': acknowledgement,
|
||||
'emp_ack': emp_ack,
|
||||
'employee': employee,
|
||||
}
|
||||
return render(request, 'accounts/simple_acknowledgements/sign.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def simple_acknowledgement_download(request, ack_id):
|
||||
"""
|
||||
Employee view: Download the signed PDF.
|
||||
"""
|
||||
emp_ack = get_object_or_404(
|
||||
EmployeeAcknowledgement,
|
||||
acknowledgement_id=ack_id,
|
||||
employee=request.user,
|
||||
is_signed=True
|
||||
)
|
||||
|
||||
if emp_ack.signed_pdf:
|
||||
response = HttpResponse(emp_ack.signed_pdf, content_type='application/pdf')
|
||||
response['Content-Disposition'] = f'attachment; filename="{emp_ack.acknowledgement.title}.pdf"'
|
||||
return response
|
||||
elif emp_ack.acknowledgement.pdf_document:
|
||||
response = HttpResponse(emp_ack.acknowledgement.pdf_document, content_type='application/pdf')
|
||||
response['Content-Disposition'] = f'attachment; filename="{emp_ack.acknowledgement.title}.pdf"'
|
||||
return response
|
||||
else:
|
||||
messages.error(request, _('No PDF available for this acknowledgement.'))
|
||||
return redirect('accounts:simple_acknowledgements:employee_list')
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ADMIN VIEWS
|
||||
# ============================================================================
|
||||
|
||||
@login_required
|
||||
def admin_acknowledgement_list(request):
|
||||
"""
|
||||
Admin view: List all acknowledgements with management options.
|
||||
"""
|
||||
if not request.user.is_staff and not request.user.is_px_admin():
|
||||
messages.error(request, _('You do not have permission to access this page.'))
|
||||
return redirect('accounts:simple_acknowledgements:employee_list')
|
||||
|
||||
acknowledgements = SimpleAcknowledgement.objects.all().order_by('order', 'title')
|
||||
|
||||
context = {
|
||||
'acknowledgements': acknowledgements,
|
||||
}
|
||||
return render(request, 'accounts/simple_acknowledgements/admin_list.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def admin_acknowledgement_create(request):
|
||||
"""
|
||||
Admin view: Create a new acknowledgement.
|
||||
"""
|
||||
if not request.user.is_staff and not request.user.is_px_admin():
|
||||
messages.error(request, _('You do not have permission to access this page.'))
|
||||
return redirect('accounts:simple_acknowledgements:employee_list')
|
||||
|
||||
if request.method == 'POST':
|
||||
title = request.POST.get('title', '').strip()
|
||||
description = request.POST.get('description', '').strip()
|
||||
pdf_document = request.FILES.get('pdf_document')
|
||||
is_required = request.POST.get('is_required') == 'on'
|
||||
is_active = request.POST.get('is_active') == 'on'
|
||||
order = request.POST.get('order', '0')
|
||||
|
||||
if not title:
|
||||
messages.error(request, _('Please enter a title.'))
|
||||
else:
|
||||
acknowledgement = SimpleAcknowledgement.objects.create(
|
||||
title=title,
|
||||
description=description,
|
||||
pdf_document=pdf_document,
|
||||
is_required=is_required,
|
||||
is_active=is_active,
|
||||
order=int(order) if order.isdigit() else 0,
|
||||
)
|
||||
messages.success(request, _('Acknowledgement created successfully!'))
|
||||
return redirect('accounts:simple_acknowledgements:admin_list')
|
||||
|
||||
return render(request, 'accounts/simple_acknowledgements/admin_create.html')
|
||||
|
||||
|
||||
@login_required
|
||||
def admin_acknowledgement_edit(request, ack_id):
|
||||
"""
|
||||
Admin view: Edit an existing acknowledgement.
|
||||
"""
|
||||
if not request.user.is_staff and not request.user.is_px_admin():
|
||||
messages.error(request, _('You do not have permission to access this page.'))
|
||||
return redirect('accounts:simple_acknowledgements:employee_list')
|
||||
|
||||
acknowledgement = get_object_or_404(SimpleAcknowledgement, id=ack_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
title = request.POST.get('title', '').strip()
|
||||
description = request.POST.get('description', '').strip()
|
||||
pdf_document = request.FILES.get('pdf_document')
|
||||
is_required = request.POST.get('is_required') == 'on'
|
||||
is_active = request.POST.get('is_active') == 'on'
|
||||
order = request.POST.get('order', '0')
|
||||
|
||||
if not title:
|
||||
messages.error(request, _('Please enter a title.'))
|
||||
else:
|
||||
acknowledgement.title = title
|
||||
acknowledgement.description = description
|
||||
acknowledgement.is_required = is_required
|
||||
acknowledgement.is_active = is_active
|
||||
acknowledgement.order = int(order) if order.isdigit() else 0
|
||||
|
||||
if pdf_document:
|
||||
acknowledgement.pdf_document = pdf_document
|
||||
|
||||
acknowledgement.save()
|
||||
messages.success(request, _('Acknowledgement updated successfully!'))
|
||||
return redirect('accounts:simple_acknowledgements:admin_list')
|
||||
|
||||
context = {
|
||||
'acknowledgement': acknowledgement,
|
||||
'action': 'edit',
|
||||
}
|
||||
return render(request, 'accounts/simple_acknowledgements/admin_form.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def admin_acknowledgement_delete(request, ack_id):
|
||||
"""
|
||||
Admin view: Delete an acknowledgement.
|
||||
"""
|
||||
if not request.user.is_staff and not request.user.is_px_admin():
|
||||
messages.error(request, _('You do not have permission to access this page.'))
|
||||
return redirect('accounts:simple_acknowledgements:employee_list')
|
||||
|
||||
acknowledgement = get_object_or_404(SimpleAcknowledgement, id=ack_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
acknowledgement.delete()
|
||||
messages.success(request, _('Acknowledgement deleted successfully!'))
|
||||
return redirect('accounts:simple_acknowledgements:admin_list')
|
||||
|
||||
context = {
|
||||
'acknowledgement': acknowledgement,
|
||||
}
|
||||
return render(request, 'accounts/simple_acknowledgements/admin_delete.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def admin_employee_signatures(request, ack_id=None):
|
||||
"""
|
||||
Admin view: View all employee signatures.
|
||||
Can filter by acknowledgement if ack_id is provided.
|
||||
"""
|
||||
if not request.user.is_staff and not request.user.is_px_admin():
|
||||
messages.error(request, _('You do not have permission to access this page.'))
|
||||
return redirect('accounts:simple_acknowledgements:employee_list')
|
||||
|
||||
signatures = EmployeeAcknowledgement.objects.select_related(
|
||||
'employee', 'acknowledgement'
|
||||
).order_by('-signed_at')
|
||||
|
||||
if ack_id:
|
||||
acknowledgement = get_object_or_404(SimpleAcknowledgement, id=ack_id)
|
||||
signatures = signatures.filter(acknowledgement=acknowledgement)
|
||||
else:
|
||||
acknowledgement = None
|
||||
|
||||
# Search
|
||||
search = request.GET.get('search', '')
|
||||
if search:
|
||||
signatures = signatures.filter(
|
||||
Q(employee__email__icontains=search) |
|
||||
Q(employee__first_name__icontains=search) |
|
||||
Q(employee__last_name__icontains=search) |
|
||||
Q(signature_name__icontains=search) |
|
||||
Q(signature_employee_id__icontains=search)
|
||||
)
|
||||
|
||||
# Filter by status
|
||||
status_filter = request.GET.get('status', '')
|
||||
if status_filter == 'signed':
|
||||
signatures = signatures.filter(is_signed=True)
|
||||
elif status_filter == 'pending':
|
||||
signatures = signatures.filter(is_signed=False)
|
||||
|
||||
# Pagination
|
||||
paginator = Paginator(signatures, 50)
|
||||
page_number = request.GET.get('page')
|
||||
page_obj = paginator.get_page(page_number)
|
||||
|
||||
context = {
|
||||
'signatures': page_obj,
|
||||
'acknowledgement': acknowledgement,
|
||||
'search': search,
|
||||
'status_filter': status_filter,
|
||||
}
|
||||
return render(request, 'accounts/simple_acknowledgements/admin_signatures.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def admin_export_signatures(request):
|
||||
"""
|
||||
Admin view: Export all signatures to CSV.
|
||||
"""
|
||||
if not request.user.is_staff and not request.user.is_px_admin():
|
||||
messages.error(request, _('You do not have permission to access this page.'))
|
||||
return redirect('accounts:simple_acknowledgements:employee_list')
|
||||
|
||||
import csv
|
||||
|
||||
response = HttpResponse(content_type='text/csv')
|
||||
response['Content-Disposition'] = 'attachment; filename="acknowledgement_signatures.csv"'
|
||||
|
||||
writer = csv.writer(response)
|
||||
writer.writerow([
|
||||
'Employee Email', 'Employee Name', 'Employee ID (at sign)',
|
||||
'Acknowledgement', 'Status', 'Signed At', 'IP Address'
|
||||
])
|
||||
|
||||
signatures = EmployeeAcknowledgement.objects.select_related(
|
||||
'employee', 'acknowledgement'
|
||||
).order_by('-signed_at')
|
||||
|
||||
for sig in signatures:
|
||||
writer.writerow([
|
||||
sig.employee.email,
|
||||
sig.signature_name or sig.employee.get_full_name(),
|
||||
sig.signature_employee_id,
|
||||
sig.acknowledgement.title,
|
||||
'Signed' if sig.is_signed else 'Pending',
|
||||
sig.signed_at.strftime('%Y-%m-%d %H:%M') if sig.signed_at else '',
|
||||
sig.ip_address or '',
|
||||
])
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@login_required
|
||||
def admin_send_acknowledgement(request, ack_id):
|
||||
"""
|
||||
Admin view: Send acknowledgement to specific staff members.
|
||||
"""
|
||||
if not request.user.is_staff and not request.user.is_px_admin():
|
||||
messages.error(request, _('You do not have permission to access this page.'))
|
||||
return redirect('accounts:simple_acknowledgements:employee_list')
|
||||
|
||||
acknowledgement = get_object_or_404(SimpleAcknowledgement, id=ack_id)
|
||||
|
||||
# Get all active staff
|
||||
from apps.accounts.models import User
|
||||
staff_list = User.objects.filter(
|
||||
is_active=True,
|
||||
is_provisional=False
|
||||
).exclude(
|
||||
id=request.user.id
|
||||
).order_by('first_name', 'last_name')
|
||||
|
||||
# Get already sent employees
|
||||
already_sent_ids = EmployeeAcknowledgement.objects.filter(
|
||||
acknowledgement=acknowledgement
|
||||
).values_list('employee_id', flat=True)
|
||||
|
||||
if request.method == 'POST':
|
||||
employee_ids = request.POST.getlist('employees')
|
||||
send_email = request.POST.get('send_email') == 'on'
|
||||
|
||||
if not employee_ids:
|
||||
messages.error(request, _('Please select at least one employee.'))
|
||||
else:
|
||||
sent_count = 0
|
||||
for emp_id in employee_ids:
|
||||
emp_ack, created = EmployeeAcknowledgement.objects.get_or_create(
|
||||
employee_id=emp_id,
|
||||
acknowledgement=acknowledgement,
|
||||
defaults={
|
||||
'sent_at': timezone.now(),
|
||||
'sent_by': request.user,
|
||||
}
|
||||
)
|
||||
if created:
|
||||
sent_count += 1
|
||||
# Update sent info if record already existed but wasn't sent
|
||||
elif not emp_ack.sent_at:
|
||||
emp_ack.sent_at = timezone.now()
|
||||
emp_ack.sent_by = request.user
|
||||
emp_ack.save()
|
||||
sent_count += 1
|
||||
|
||||
# Send email notifications if requested
|
||||
if send_email and sent_count > 0:
|
||||
try:
|
||||
from apps.notifications.services import NotificationService
|
||||
|
||||
employees = User.objects.filter(id__in=employee_ids)
|
||||
for emp in employees:
|
||||
NotificationService.send_email(
|
||||
email=emp.email,
|
||||
subject=f'Action Required: {acknowledgement.title}',
|
||||
message=f'''Dear {emp.get_full_name() or emp.email},
|
||||
|
||||
You have been requested to sign the following acknowledgement: {acknowledgement.title}
|
||||
|
||||
Please log in to the system to review and sign this document.
|
||||
|
||||
Best regards,
|
||||
HR Department''',
|
||||
related_object=emp_ack,
|
||||
)
|
||||
except Exception as e:
|
||||
messages.warning(request, _('Acknowledgements sent but email notification failed.'))
|
||||
|
||||
messages.success(request, _('Acknowledgement sent to {count} employee(s).').format(count=sent_count))
|
||||
return redirect('accounts:simple_acknowledgements:admin_list')
|
||||
|
||||
context = {
|
||||
'acknowledgement': acknowledgement,
|
||||
'staff_list': staff_list,
|
||||
'already_sent_ids': list(already_sent_ids),
|
||||
}
|
||||
return render(request, 'accounts/simple_acknowledgements/admin_send.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def admin_upload_signed_pdf(request, signature_id):
|
||||
"""
|
||||
Admin view: Upload signed PDF on behalf of an employee.
|
||||
"""
|
||||
if not request.user.is_staff and not request.user.is_px_admin():
|
||||
messages.error(request, _('You do not have permission to access this page.'))
|
||||
return redirect('accounts:simple_acknowledgements:employee_list')
|
||||
|
||||
emp_ack = get_object_or_404(
|
||||
EmployeeAcknowledgement.objects.select_related('employee', 'acknowledgement'),
|
||||
id=signature_id
|
||||
)
|
||||
|
||||
if request.method == 'POST':
|
||||
signed_pdf = request.FILES.get('signed_pdf')
|
||||
signature_name = request.POST.get('signature_name', emp_ack.employee.get_full_name())
|
||||
signature_employee_id = request.POST.get('signature_employee_id', '')
|
||||
notes = request.POST.get('notes', '')
|
||||
|
||||
if not signed_pdf:
|
||||
messages.error(request, _('Please upload a signed PDF.'))
|
||||
else:
|
||||
emp_ack.signed_pdf = signed_pdf
|
||||
emp_ack.is_signed = True
|
||||
emp_ack.signed_at = timezone.now()
|
||||
emp_ack.signature_name = signature_name
|
||||
emp_ack.signature_employee_id = signature_employee_id
|
||||
emp_ack.notes = notes
|
||||
emp_ack.save()
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
_('Signed PDF uploaded successfully for {name}.').format(name=emp_ack.employee.get_full_name())
|
||||
)
|
||||
return redirect('accounts:simple_acknowledgements:admin_signatures')
|
||||
|
||||
context = {
|
||||
'emp_ack': emp_ack,
|
||||
'employee': emp_ack.employee,
|
||||
'acknowledgement': emp_ack.acknowledgement,
|
||||
}
|
||||
return render(request, 'accounts/simple_acknowledgements/admin_upload_pdf.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def admin_mark_as_sent(request, ack_id):
|
||||
"""
|
||||
Admin view: Mark acknowledgement as sent to all employees (bulk action).
|
||||
"""
|
||||
if not request.user.is_staff and not request.user.is_px_admin():
|
||||
messages.error(request, _('You do not have permission to access this page.'))
|
||||
return redirect('accounts:simple_acknowledgements:employee_list')
|
||||
|
||||
acknowledgement = get_object_or_404(SimpleAcknowledgement, id=ack_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
from apps.accounts.models import User
|
||||
|
||||
# Get all active employees who haven't been sent this yet
|
||||
all_employees = User.objects.filter(is_active=True, is_provisional=False)
|
||||
already_sent = EmployeeAcknowledgement.objects.filter(
|
||||
acknowledgement=acknowledgement
|
||||
).values_list('employee_id', flat=True)
|
||||
|
||||
new_employees = all_employees.exclude(id__in=already_sent)
|
||||
|
||||
created_count = 0
|
||||
for emp in new_employees:
|
||||
EmployeeAcknowledgement.objects.create(
|
||||
employee=emp,
|
||||
acknowledgement=acknowledgement,
|
||||
sent_at=timezone.now(),
|
||||
sent_by=request.user,
|
||||
)
|
||||
created_count += 1
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
_('Acknowledgement sent to {count} new employee(s).').format(count=created_count)
|
||||
)
|
||||
return redirect('accounts:simple_acknowledgements:admin_list')
|
||||
|
||||
return redirect('accounts:simple_acknowledgements:admin_list')
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HELPER FUNCTIONS
|
||||
# ============================================================================
|
||||
|
||||
def get_client_ip(request):
|
||||
"""Get client IP address from request."""
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(',')[0]
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
return ip
|
||||
@ -2,25 +2,18 @@ from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from rest_framework_simplejwt.views import TokenRefreshView
|
||||
|
||||
from .views import (
|
||||
AcknowledgementChecklistItemViewSet,
|
||||
AcknowledgementContentViewSet,
|
||||
CustomTokenObtainPairView,
|
||||
RoleViewSet,
|
||||
UserAcknowledgementViewSet,
|
||||
UserViewSet,
|
||||
user_settings,
|
||||
)
|
||||
# Import ViewSets directly from views.py file
|
||||
from apps.accounts import views as account_views_main
|
||||
|
||||
# Import specific items from views
|
||||
from apps.accounts.views import user_settings, CustomTokenObtainPairView
|
||||
|
||||
from .ui_views import (
|
||||
acknowledgement_checklist_list,
|
||||
acknowledgement_content_list,
|
||||
acknowledgement_dashboard,
|
||||
bulk_deactivate_users,
|
||||
bulk_invite_users,
|
||||
bulk_resend_invitations,
|
||||
change_password_view,
|
||||
CustomPasswordResetConfirmView,
|
||||
export_acknowledgements,
|
||||
export_provisional_users,
|
||||
login_view,
|
||||
logout_view,
|
||||
@ -39,13 +32,13 @@ from .ui_views import (
|
||||
app_name = 'accounts'
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'users', UserViewSet, basename='user')
|
||||
router.register(r'roles', RoleViewSet, basename='role')
|
||||
router.register(r'onboarding/content', AcknowledgementContentViewSet, basename='acknowledgement-content')
|
||||
router.register(r'onboarding/checklist', AcknowledgementChecklistItemViewSet, basename='acknowledgement-checklist')
|
||||
router.register(r'onboarding/acknowledgements', UserAcknowledgementViewSet, basename='user-acknowledgement')
|
||||
router.register(r'users', account_views_main.UserViewSet, basename='user')
|
||||
router.register(r'roles', account_views_main.RoleViewSet, basename='role')
|
||||
|
||||
urlpatterns = [
|
||||
# Simple Acknowledgement URLs (simplified system)
|
||||
path('acknowledgements/', include('apps.accounts.simple_acknowledgement_urls')),
|
||||
|
||||
# UI Authentication URLs
|
||||
path('login/', login_view, name='login'),
|
||||
path('logout/', logout_view, name='logout'),
|
||||
@ -53,14 +46,14 @@ urlpatterns = [
|
||||
path('password/reset/', password_reset_view, name='password_reset'),
|
||||
path('password/reset/confirm/<uidb64>/<token>/', CustomPasswordResetConfirmView.as_view(), name='password_reset_confirm'),
|
||||
path('password/change/', change_password_view, name='password_change'),
|
||||
|
||||
|
||||
# JWT Authentication
|
||||
path('token/', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||
path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||
|
||||
|
||||
# User and Role endpoints
|
||||
path('', include(router.urls)),
|
||||
|
||||
|
||||
# Onboarding Wizard UI
|
||||
path('onboarding/activate/<str:token>/', onboarding_activate, name='onboarding-activate'),
|
||||
path('onboarding/welcome/', onboarding_welcome, name='onboarding-welcome'),
|
||||
@ -68,20 +61,14 @@ urlpatterns = [
|
||||
path('onboarding/wizard/checklist/', onboarding_step_checklist, name='onboarding-step-checklist'),
|
||||
path('onboarding/wizard/activation/', onboarding_step_activation, name='onboarding-step-activation'),
|
||||
path('onboarding/complete/', onboarding_complete, name='onboarding-complete'),
|
||||
|
||||
|
||||
# Provisional User Management
|
||||
path('onboarding/provisional/', provisional_user_list, name='provisional-user-list'),
|
||||
path('onboarding/provisional/<uuid:user_id>/progress/', provisional_user_progress, name='provisional-user-progress'),
|
||||
path('onboarding/bulk-invite/', bulk_invite_users, name='bulk-invite-users'),
|
||||
path('onboarding/bulk-resend/', bulk_resend_invitations, name='bulk-resend-invitations'),
|
||||
path('onboarding/bulk-deactivate/', bulk_deactivate_users, name='bulk-deactivate-users'),
|
||||
path('onboarding/export/acknowledgements/', export_acknowledgements, name='export-acknowledgements'),
|
||||
path('onboarding/export/users/', export_provisional_users, name='export-provisional-users'),
|
||||
|
||||
# Acknowledgement Management
|
||||
path('onboarding/content/', acknowledgement_content_list, name='acknowledgement-content-list'),
|
||||
path('onboarding/checklist-items/', acknowledgement_checklist_list, name='acknowledgement-checklist-list'),
|
||||
path('onboarding/dashboard/', acknowledgement_dashboard, name='acknowledgement-dashboard'),
|
||||
path('onboarding/preview/', preview_wizard_as_role, name='preview-wizard'),
|
||||
path('onboarding/preview/<str:role>/', preview_wizard_as_role, name='preview-wizard-role'),
|
||||
]
|
||||
|
||||
@ -149,6 +149,10 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
if user.is_department_manager() and user.department:
|
||||
return queryset.filter(department=user.department).select_related('hospital', 'department')
|
||||
|
||||
# Source Users see only themselves
|
||||
if user.is_source_user():
|
||||
return queryset.filter(id=user.id)
|
||||
|
||||
# Others see only themselves
|
||||
return queryset.filter(id=user.id)
|
||||
|
||||
|
||||
@ -18,21 +18,51 @@ from apps.physicians.models import PhysicianMonthlyRating
|
||||
|
||||
from .models import KPI, KPIValue
|
||||
from .services import UnifiedAnalyticsService, ExportService
|
||||
from apps.core.decorators import block_source_user
|
||||
import json
|
||||
|
||||
|
||||
def serialize_queryset_values(queryset):
|
||||
"""Properly serialize QuerySet values to JSON string."""
|
||||
if queryset is None:
|
||||
return '[]'
|
||||
data = list(queryset)
|
||||
result = []
|
||||
for item in data:
|
||||
row = {}
|
||||
for key, value in item.items():
|
||||
# Convert UUID to string
|
||||
if hasattr(value, 'hex'): # UUID object
|
||||
row[key] = str(value)
|
||||
# Convert Python None to JavaScript null
|
||||
elif value is None:
|
||||
row[key] = None
|
||||
else:
|
||||
row[key] = value
|
||||
result.append(row)
|
||||
return json.dumps(result, default=str)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def analytics_dashboard(request):
|
||||
"""
|
||||
Analytics dashboard with KPIs and charts.
|
||||
|
||||
Features:
|
||||
- KPI cards with current values
|
||||
|
||||
Comprehensive dashboard showing:
|
||||
- KPI cards with current values for Complaints, Actions, Surveys, Feedback
|
||||
- Trend charts
|
||||
- Department rankings
|
||||
- Physician rankings
|
||||
- Source distribution
|
||||
- Status breakdown
|
||||
"""
|
||||
user = request.user
|
||||
from apps.feedback.models import Feedback
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from django.db.models.functions import TruncDate, TruncMonth
|
||||
|
||||
user = request.user
|
||||
|
||||
# Get hospital filter
|
||||
hospital_filter = request.GET.get('hospital')
|
||||
if hospital_filter:
|
||||
@ -41,31 +71,104 @@ def analytics_dashboard(request):
|
||||
hospital = user.hospital
|
||||
else:
|
||||
hospital = None
|
||||
|
||||
# Calculate key metrics
|
||||
|
||||
# Base querysets
|
||||
complaints_queryset = Complaint.objects.all()
|
||||
actions_queryset = PXAction.objects.all()
|
||||
surveys_queryset = SurveyInstance.objects.filter(status='completed')
|
||||
|
||||
feedback_queryset = Feedback.objects.all()
|
||||
|
||||
if hospital:
|
||||
complaints_queryset = complaints_queryset.filter(hospital=hospital)
|
||||
actions_queryset = actions_queryset.filter(hospital=hospital)
|
||||
surveys_queryset = surveys_queryset.filter(survey_template__hospital=hospital)
|
||||
feedback_queryset = feedback_queryset.filter(hospital=hospital)
|
||||
|
||||
# ============ COMPLAINTS KPIs ============
|
||||
total_complaints = complaints_queryset.count()
|
||||
open_complaints = complaints_queryset.filter(status='open').count()
|
||||
in_progress_complaints = complaints_queryset.filter(status='in_progress').count()
|
||||
resolved_complaints = complaints_queryset.filter(status='resolved').count()
|
||||
closed_complaints = complaints_queryset.filter(status='closed').count()
|
||||
overdue_complaints = complaints_queryset.filter(is_overdue=True).count()
|
||||
|
||||
# KPI calculations
|
||||
kpis = {
|
||||
'total_complaints': complaints_queryset.count(),
|
||||
'open_complaints': complaints_queryset.filter(status='open').count(),
|
||||
'overdue_complaints': complaints_queryset.filter(is_overdue=True).count(),
|
||||
'total_actions': actions_queryset.count(),
|
||||
'open_actions': actions_queryset.filter(status='open').count(),
|
||||
'overdue_actions': actions_queryset.filter(is_overdue=True).count(),
|
||||
'avg_survey_score': surveys_queryset.aggregate(avg=Avg('total_score'))['avg'] or 0,
|
||||
'negative_surveys': surveys_queryset.filter(is_negative=True).count(),
|
||||
}
|
||||
# Complaint sources
|
||||
complaint_sources = complaints_queryset.values('source').annotate(count=Count('id')).order_by('-count')[:6]
|
||||
|
||||
# Complaint domains (Level 1)
|
||||
top_domains = complaints_queryset.filter(domain__isnull=False).values('domain__name_en').annotate(count=Count('id')).order_by('-count')[:5]
|
||||
|
||||
# Complaint categories (Level 2)
|
||||
top_categories = complaints_queryset.filter(category__isnull=False).values('category__name_en').annotate(count=Count('id')).order_by('-count')[:5]
|
||||
|
||||
# Complaint severity
|
||||
severity_breakdown = complaints_queryset.values('severity').annotate(count=Count('id')).order_by('-count')
|
||||
|
||||
# Department rankings (top 5 by survey score)
|
||||
# Query from SurveyInstance directly and annotate with department
|
||||
# Status breakdown
|
||||
status_breakdown = complaints_queryset.values('status').annotate(count=Count('id')).order_by('-count')
|
||||
|
||||
# ============ ACTIONS KPIs ============
|
||||
total_actions = actions_queryset.count()
|
||||
open_actions = actions_queryset.filter(status='open').count()
|
||||
in_progress_actions = actions_queryset.filter(status='in_progress').count()
|
||||
approved_actions = actions_queryset.filter(status='approved').count()
|
||||
closed_actions = actions_queryset.filter(status='closed').count()
|
||||
overdue_actions = actions_queryset.filter(is_overdue=True).count()
|
||||
|
||||
# Action sources
|
||||
action_sources = actions_queryset.values('source_type').annotate(count=Count('id')).order_by('-count')[:6]
|
||||
|
||||
# Action categories
|
||||
action_categories = actions_queryset.exclude(category='').values('category').annotate(count=Count('id')).order_by('-count')[:5]
|
||||
|
||||
# ============ SURVEYS KPIs ============
|
||||
total_surveys = surveys_queryset.count()
|
||||
avg_survey_score = surveys_queryset.aggregate(avg=Avg('total_score'))['avg'] or 0
|
||||
negative_surveys = surveys_queryset.filter(is_negative=True).count()
|
||||
|
||||
# Survey completion rate
|
||||
all_surveys = SurveyInstance.objects.all()
|
||||
if hospital:
|
||||
all_surveys = all_surveys.filter(survey_template__hospital=hospital)
|
||||
total_sent = all_surveys.count()
|
||||
completed_surveys = all_surveys.filter(status='completed').count()
|
||||
completion_rate = (completed_surveys / total_sent * 100) if total_sent > 0 else 0
|
||||
|
||||
# Survey types
|
||||
survey_types = all_surveys.values('survey_template__survey_type').annotate(count=Count('id')).order_by('-count')[:5]
|
||||
|
||||
# ============ FEEDBACK KPIs ============
|
||||
total_feedback = feedback_queryset.count()
|
||||
compliments = feedback_queryset.filter(feedback_type='compliment').count()
|
||||
suggestions = feedback_queryset.filter(feedback_type='suggestion').count()
|
||||
|
||||
# Sentiment analysis
|
||||
sentiment_breakdown = feedback_queryset.values('sentiment').annotate(count=Count('id')).order_by('-count')
|
||||
|
||||
# Feedback categories
|
||||
feedback_categories = feedback_queryset.values('category').annotate(count=Count('id')).order_by('-count')[:5]
|
||||
|
||||
# Average rating
|
||||
avg_rating = feedback_queryset.filter(rating__isnull=False).aggregate(avg=Avg('rating'))['avg'] or 0
|
||||
|
||||
# ============ TRENDS (Last 30 days) ============
|
||||
thirty_days_ago = timezone.now() - timedelta(days=30)
|
||||
|
||||
# Complaint trends
|
||||
complaint_trend = complaints_queryset.filter(
|
||||
created_at__gte=thirty_days_ago
|
||||
).annotate(
|
||||
day=TruncDate('created_at')
|
||||
).values('day').annotate(count=Count('id')).order_by('day')
|
||||
|
||||
# Survey score trend
|
||||
survey_score_trend = surveys_queryset.filter(
|
||||
completed_at__gte=thirty_days_ago
|
||||
).annotate(
|
||||
day=TruncDate('completed_at')
|
||||
).values('day').annotate(avg_score=Avg('total_score')).order_by('day')
|
||||
|
||||
# ============ DEPARTMENT RANKINGS ============
|
||||
department_rankings = Department.objects.filter(
|
||||
status='active'
|
||||
).annotate(
|
||||
@ -76,26 +179,143 @@ def analytics_dashboard(request):
|
||||
survey_count=Count(
|
||||
'journey_instances__surveys',
|
||||
filter=Q(journey_instances__surveys__status='completed')
|
||||
)
|
||||
),
|
||||
complaint_count=Count('complaints'),
|
||||
action_count=Count('px_actions')
|
||||
).filter(
|
||||
survey_count__gt=0
|
||||
).order_by('-avg_score')[:5]
|
||||
|
||||
).order_by('-avg_score')[:7]
|
||||
|
||||
# ============ TIME-BASED CALCULATIONS ============
|
||||
# Average resolution time (complaints)
|
||||
resolved_with_time = complaints_queryset.filter(
|
||||
status__in=['resolved', 'closed'],
|
||||
resolved_at__isnull=False,
|
||||
created_at__isnull=False
|
||||
)
|
||||
if resolved_with_time.exists():
|
||||
avg_resolution_hours = resolved_with_time.annotate(
|
||||
resolution_time=F('resolved_at') - F('created_at')
|
||||
).aggregate(avg=Avg('resolution_time'))['avg']
|
||||
if avg_resolution_hours:
|
||||
avg_resolution_hours = avg_resolution_hours.total_seconds() / 3600
|
||||
else:
|
||||
avg_resolution_hours = 0
|
||||
else:
|
||||
avg_resolution_hours = 0
|
||||
|
||||
# Average action completion time
|
||||
closed_actions_with_time = actions_queryset.filter(
|
||||
status='closed',
|
||||
closed_at__isnull=False,
|
||||
created_at__isnull=False
|
||||
)
|
||||
if closed_actions_with_time.exists():
|
||||
avg_action_days = closed_actions_with_time.annotate(
|
||||
completion_time=F('closed_at') - F('created_at')
|
||||
).aggregate(avg=Avg('completion_time'))['avg']
|
||||
if avg_action_days:
|
||||
avg_action_days = avg_action_days.days
|
||||
else:
|
||||
avg_action_days = 0
|
||||
else:
|
||||
avg_action_days = 0
|
||||
|
||||
# ============ SLA COMPLIANCE ============
|
||||
total_with_sla = complaints_queryset.filter(due_at__isnull=False).count()
|
||||
resolved_within_sla = complaints_queryset.filter(
|
||||
status__in=['resolved', 'closed'],
|
||||
resolved_at__lte=F('due_at')
|
||||
).count()
|
||||
sla_compliance = (resolved_within_sla / total_with_sla * 100) if total_with_sla > 0 else 0
|
||||
|
||||
# ============ NPS CALCULATION ============
|
||||
# NPS = % Promoters (9-10) - % Detractors (0-6)
|
||||
nps_surveys = surveys_queryset.filter(
|
||||
survey_template__survey_type='nps',
|
||||
total_score__isnull=False
|
||||
)
|
||||
if nps_surveys.exists():
|
||||
promoters = nps_surveys.filter(total_score__gte=9).count()
|
||||
detractors = nps_surveys.filter(total_score__lte=6).count()
|
||||
total_nps = nps_surveys.count()
|
||||
nps_score = ((promoters - detractors) / total_nps * 100) if total_nps > 0 else 0
|
||||
else:
|
||||
nps_score = 0
|
||||
|
||||
# Get hospitals for filter
|
||||
hospitals = Hospital.objects.filter(status='active')
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
hospitals = hospitals.filter(id=user.hospital.id)
|
||||
|
||||
|
||||
# Build comprehensive KPI data
|
||||
kpis = {
|
||||
# Complaints
|
||||
'total_complaints': total_complaints,
|
||||
'open_complaints': open_complaints,
|
||||
'in_progress_complaints': in_progress_complaints,
|
||||
'resolved_complaints': resolved_complaints,
|
||||
'closed_complaints': closed_complaints,
|
||||
'overdue_complaints': overdue_complaints,
|
||||
'avg_resolution_hours': round(avg_resolution_hours, 1),
|
||||
'sla_compliance': round(sla_compliance, 1),
|
||||
|
||||
# Actions
|
||||
'total_actions': total_actions,
|
||||
'open_actions': open_actions,
|
||||
'in_progress_actions': in_progress_actions,
|
||||
'approved_actions': approved_actions,
|
||||
'closed_actions': closed_actions,
|
||||
'overdue_actions': overdue_actions,
|
||||
'avg_action_days': round(avg_action_days, 1),
|
||||
|
||||
# Surveys
|
||||
'total_surveys': total_surveys,
|
||||
'avg_survey_score': round(avg_survey_score, 2),
|
||||
'nps_score': round(nps_score, 1),
|
||||
'negative_surveys': negative_surveys,
|
||||
'completion_rate': round(completion_rate, 1),
|
||||
|
||||
# Feedback
|
||||
'total_feedback': total_feedback,
|
||||
'compliments': compliments,
|
||||
'suggestions': suggestions,
|
||||
'avg_rating': round(avg_rating, 2),
|
||||
}
|
||||
|
||||
context = {
|
||||
'kpis': kpis,
|
||||
'department_rankings': department_rankings,
|
||||
'hospitals': hospitals,
|
||||
'selected_hospital': hospital,
|
||||
|
||||
# Complaint analytics - serialize properly for JSON
|
||||
'complaint_sources': serialize_queryset_values(complaint_sources),
|
||||
'top_domains': serialize_queryset_values(top_domains),
|
||||
'top_categories': serialize_queryset_values(top_categories),
|
||||
'severity_breakdown': serialize_queryset_values(severity_breakdown),
|
||||
'status_breakdown': serialize_queryset_values(status_breakdown),
|
||||
'complaint_trend': serialize_queryset_values(complaint_trend),
|
||||
|
||||
# Action analytics
|
||||
'action_sources': serialize_queryset_values(action_sources),
|
||||
'action_categories': serialize_queryset_values(action_categories),
|
||||
|
||||
# Survey analytics
|
||||
'survey_types': serialize_queryset_values(survey_types),
|
||||
'survey_score_trend': serialize_queryset_values(survey_score_trend),
|
||||
|
||||
# Feedback analytics
|
||||
'sentiment_breakdown': serialize_queryset_values(sentiment_breakdown),
|
||||
'feedback_categories': serialize_queryset_values(feedback_categories),
|
||||
|
||||
# Department rankings
|
||||
'department_rankings': department_rankings,
|
||||
}
|
||||
|
||||
|
||||
return render(request, 'analytics/dashboard.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def kpi_list(request):
|
||||
"""KPI definitions list view"""
|
||||
@ -130,6 +350,7 @@ def kpi_list(request):
|
||||
return render(request, 'analytics/kpi_list.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def command_center(request):
|
||||
"""
|
||||
@ -191,6 +412,7 @@ def command_center(request):
|
||||
return render(request, 'analytics/command_center.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def command_center_api(request):
|
||||
"""
|
||||
@ -350,6 +572,7 @@ def command_center_api(request):
|
||||
})
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def export_command_center(request, export_format):
|
||||
"""
|
||||
|
||||
@ -11,7 +11,7 @@ from django.utils import timezone
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from uuid import UUID
|
||||
|
||||
from apps.complaints.models import Complaint, Inquiry
|
||||
from apps.complaints.models import Complaint, Inquiry, ComplaintSource
|
||||
from apps.px_sources.models import PXSource
|
||||
from apps.core.services import AuditService
|
||||
from apps.organizations.models import Department, Hospital, Patient, Staff
|
||||
@ -28,8 +28,13 @@ def interaction_list(request):
|
||||
|
||||
# Apply RBAC filters
|
||||
user = request.user
|
||||
# Get selected hospital for PX Admins (from middleware)
|
||||
selected_hospital = getattr(request, 'tenant_hospital', None)
|
||||
|
||||
if user.is_px_admin():
|
||||
pass
|
||||
# PX Admins see all, but filter by selected hospital if set
|
||||
if selected_hospital:
|
||||
queryset = queryset.filter(hospital=selected_hospital)
|
||||
elif user.hospital:
|
||||
queryset = queryset.filter(hospital=user.hospital)
|
||||
else:
|
||||
@ -251,7 +256,7 @@ def complaint_success(request, pk):
|
||||
def complaint_list(request):
|
||||
"""List complaints created by call center"""
|
||||
queryset = Complaint.objects.filter(
|
||||
source=ComplaintSource.CALL_CENTER
|
||||
complaint_source=ComplaintSource.CALL_CENTER
|
||||
).select_related(
|
||||
'patient', 'hospital', 'department', 'staff', 'assigned_to'
|
||||
)
|
||||
|
||||
@ -101,7 +101,7 @@ class ComplaintAdmin(admin.ModelAdmin):
|
||||
'fields': ('created_by',)
|
||||
}),
|
||||
('Status & Assignment', {
|
||||
'fields': ('status', 'assigned_to', 'assigned_at')
|
||||
'fields': ('status', 'assigned_to', 'assigned_at', 'activated_at')
|
||||
}),
|
||||
('SLA Tracking', {
|
||||
'fields': ('due_at', 'is_overdue', 'reminder_sent_at', 'escalated_at')
|
||||
@ -119,7 +119,7 @@ class ComplaintAdmin(admin.ModelAdmin):
|
||||
)
|
||||
|
||||
readonly_fields = [
|
||||
'assigned_at', 'reminder_sent_at', 'escalated_at',
|
||||
'assigned_at', 'activated_at', 'reminder_sent_at', 'escalated_at',
|
||||
'resolved_at', 'closed_at', 'resolution_survey_sent_at',
|
||||
'created_at', 'updated_at'
|
||||
]
|
||||
@ -142,17 +142,17 @@ class ComplaintAdmin(admin.ModelAdmin):
|
||||
"""Display location hierarchy in admin"""
|
||||
parts = []
|
||||
if obj.location:
|
||||
parts.append(obj.location.name)
|
||||
parts.append(obj.location.name_en or obj.location.name_ar or str(obj.location))
|
||||
if obj.main_section:
|
||||
parts.append(obj.main_section.name)
|
||||
parts.append(obj.main_section.name_en or obj.main_section.name_ar or str(obj.main_section))
|
||||
if obj.subsection:
|
||||
parts.append(obj.subsection.name)
|
||||
parts.append(obj.subsection.name_en or obj.subsection.name_ar or str(obj.subsection))
|
||||
|
||||
if not parts:
|
||||
return '—'
|
||||
|
||||
hierarchy = ' → '.join(parts)
|
||||
return format_html('<span class="text-muted">{}</span>', hierarchy)
|
||||
return format_html('<span class="text-muted">{0}</span>', hierarchy)
|
||||
location_hierarchy.short_description = 'Location'
|
||||
|
||||
def severity_badge(self, obj):
|
||||
@ -165,7 +165,7 @@ class ComplaintAdmin(admin.ModelAdmin):
|
||||
}
|
||||
color = colors.get(obj.severity, 'secondary')
|
||||
return format_html(
|
||||
'<span class="badge bg-{}">{}</span>',
|
||||
'<span class="badge bg-{0}">{1}</span>',
|
||||
color,
|
||||
obj.get_severity_display()
|
||||
)
|
||||
@ -182,7 +182,7 @@ class ComplaintAdmin(admin.ModelAdmin):
|
||||
}
|
||||
color = colors.get(obj.status, 'secondary')
|
||||
return format_html(
|
||||
'<span class="badge bg-{}">{}</span>',
|
||||
'<span class="badge bg-{0}">{1}</span>',
|
||||
color,
|
||||
obj.get_status_display()
|
||||
)
|
||||
@ -196,7 +196,7 @@ class ComplaintAdmin(admin.ModelAdmin):
|
||||
}
|
||||
color = colors.get(obj.complaint_type, 'secondary')
|
||||
return format_html(
|
||||
'<span class="badge bg-{}">{}</span>',
|
||||
'<span class="badge bg-{0}">{1}</span>',
|
||||
color,
|
||||
obj.get_complaint_type_display()
|
||||
)
|
||||
@ -386,13 +386,13 @@ class ComplaintSLAConfigAdmin(admin.ModelAdmin):
|
||||
"""Display reminder timing method"""
|
||||
if obj.source and obj.first_reminder_hours_after:
|
||||
return format_html(
|
||||
'<span class="badge bg-primary">Source-based: {}h / {}h</span>',
|
||||
'<span class="badge bg-primary">Source-based: {0}h / {1}h</span>',
|
||||
obj.first_reminder_hours_after,
|
||||
obj.second_reminder_hours_after or 'N/A'
|
||||
)
|
||||
elif obj.reminder_hours_before:
|
||||
return format_html(
|
||||
'<span class="badge bg-info">Deadline-based: {}h before</span>',
|
||||
'<span class="badge bg-info">Deadline-based: {0}h before</span>',
|
||||
obj.reminder_hours_before
|
||||
)
|
||||
else:
|
||||
|
||||
@ -24,6 +24,7 @@ from apps.complaints.models import (
|
||||
ComplaintInvolvedStaff,
|
||||
)
|
||||
from apps.core.models import PriorityChoices, SeverityChoices
|
||||
from apps.core.form_mixins import HospitalFieldMixin, DepartmentFieldMixin
|
||||
from apps.organizations.models import Department, Hospital, Patient, Staff
|
||||
|
||||
|
||||
@ -429,13 +430,17 @@ class PublicComplaintForm(forms.ModelForm):
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class ComplaintForm(forms.ModelForm):
|
||||
class ComplaintForm(HospitalFieldMixin, forms.ModelForm):
|
||||
"""
|
||||
Form for creating complaints by authenticated users.
|
||||
|
||||
Updated to use location hierarchy (Location, Section, Subsection).
|
||||
Includes new fields for detailed patient information and complaint type.
|
||||
Uses cascading dropdowns for location selection.
|
||||
|
||||
Hospital field visibility:
|
||||
- PX Admins: See dropdown with all hospitals
|
||||
- Others: Hidden field, auto-set to user's hospital
|
||||
"""
|
||||
|
||||
# Complaint Type
|
||||
@ -456,6 +461,15 @@ class ComplaintForm(forms.ModelForm):
|
||||
widget=forms.Select(attrs={'class': 'form-select', 'id': 'complaintSourceType'})
|
||||
)
|
||||
|
||||
# PX Source (optional)
|
||||
source = forms.ModelChoiceField(
|
||||
label=_("PX Source"),
|
||||
queryset=None,
|
||||
empty_label=_("Select source (optional)"),
|
||||
required=False,
|
||||
widget=forms.Select(attrs={'class': 'form-select', 'id': 'sourceSelect'})
|
||||
)
|
||||
|
||||
# Patient Information (text-based fields only)
|
||||
relation_to_patient = forms.ChoiceField(
|
||||
label=_("Relation to Patient"),
|
||||
@ -577,17 +591,18 @@ class ComplaintForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Complaint
|
||||
fields = [
|
||||
'complaint_type', 'complaint_source_type',
|
||||
'relation_to_patient', 'patient_name',
|
||||
'national_id', 'incident_date', 'hospital', 'department',
|
||||
'complaint_type', 'complaint_source_type', 'source',
|
||||
'relation_to_patient', 'patient_name',
|
||||
'national_id', 'incident_date', 'hospital', 'department',
|
||||
'location', 'main_section', 'subsection', 'staff', 'staff_name',
|
||||
'encounter_id', 'description', 'expected_result'
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
user = kwargs.pop('user', None)
|
||||
# Note: user is handled by HospitalFieldMixin
|
||||
super().__init__(*args, **kwargs)
|
||||
from apps.organizations.models import Location, MainSection, SubSection
|
||||
from apps.px_sources.models import PXSource
|
||||
|
||||
# Initialize cascading dropdowns with empty querysets
|
||||
self.fields['main_section'].queryset = MainSection.objects.none()
|
||||
@ -596,6 +611,9 @@ class ComplaintForm(forms.ModelForm):
|
||||
# Load all locations (no filtering needed)
|
||||
self.fields['location'].queryset = Location.objects.all().order_by('name_en')
|
||||
|
||||
# Load active PX sources for optional selection
|
||||
self.fields['source'].queryset = PXSource.objects.filter(is_active=True).order_by('name_en')
|
||||
|
||||
# Check both initial data and POST data for location to load sections
|
||||
location_id = None
|
||||
if 'location' in self.initial:
|
||||
@ -626,25 +644,23 @@ class ComplaintForm(forms.ModelForm):
|
||||
main_section_id=section_id
|
||||
).order_by('name_en')
|
||||
|
||||
# Filter hospitals based on user permissions
|
||||
if user and not user.is_px_admin() and user.hospital:
|
||||
self.fields['hospital'].queryset = Hospital.objects.filter(id=user.hospital.id)
|
||||
self.fields['hospital'].initial = user.hospital
|
||||
|
||||
# Check for hospital selection in both initial data and POST data
|
||||
# Hospital field is configured by HospitalFieldMixin
|
||||
# Now filter departments and staff based on hospital
|
||||
hospital_id = None
|
||||
if 'hospital' in self.data:
|
||||
if self.data.get('hospital'):
|
||||
hospital_id = self.data.get('hospital')
|
||||
elif 'hospital' in self.initial:
|
||||
elif self.initial.get('hospital'):
|
||||
hospital_id = self.initial.get('hospital')
|
||||
|
||||
elif self.user and self.user.hospital:
|
||||
hospital_id = self.user.hospital.id
|
||||
|
||||
if hospital_id:
|
||||
# Filter departments based on selected hospital
|
||||
self.fields['department'].queryset = Department.objects.filter(
|
||||
hospital_id=hospital_id,
|
||||
status='active'
|
||||
).order_by('name')
|
||||
|
||||
|
||||
# Filter staff based on selected hospital
|
||||
self.fields['staff'].queryset = Staff.objects.filter(
|
||||
hospital_id=hospital_id,
|
||||
@ -652,12 +668,16 @@ class ComplaintForm(forms.ModelForm):
|
||||
).order_by('first_name', 'last_name')
|
||||
|
||||
|
||||
class InquiryForm(forms.ModelForm):
|
||||
class InquiryForm(HospitalFieldMixin, forms.ModelForm):
|
||||
"""
|
||||
Form for creating inquiries by authenticated users.
|
||||
|
||||
Similar to ComplaintForm - supports patient search, department filtering,
|
||||
and proper field validation with AJAX support.
|
||||
|
||||
Hospital field visibility:
|
||||
- PX Admins: See dropdown with all hospitals
|
||||
- Others: Hidden field, auto-set to user's hospital
|
||||
"""
|
||||
|
||||
patient = forms.ModelChoiceField(
|
||||
@ -724,21 +744,18 @@ class InquiryForm(forms.ModelForm):
|
||||
'contact_name', 'contact_phone', 'contact_email']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
user = kwargs.pop('user', None)
|
||||
# Note: user is handled by HospitalFieldMixin
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Filter hospitals based on user role
|
||||
if user and not user.is_px_admin() and user.hospital:
|
||||
self.fields['hospital'].queryset = Hospital.objects.filter(id=user.hospital.id)
|
||||
self.fields['hospital'].initial = user.hospital
|
||||
self.fields['hospital'].widget.attrs['readonly'] = True
|
||||
|
||||
# Check for hospital selection in both initial data and POST data
|
||||
# Hospital field is configured by HospitalFieldMixin
|
||||
# Now filter departments based on hospital
|
||||
hospital_id = None
|
||||
if 'hospital' in self.data:
|
||||
if self.data.get('hospital'):
|
||||
hospital_id = self.data.get('hospital')
|
||||
elif 'hospital' in self.initial:
|
||||
elif self.initial.get('hospital'):
|
||||
hospital_id = self.initial.get('hospital')
|
||||
elif self.user and self.user.hospital:
|
||||
hospital_id = self.user.hospital.id
|
||||
|
||||
if hospital_id:
|
||||
# Filter departments based on selected hospital
|
||||
@ -869,8 +886,14 @@ class EscalationRuleForm(forms.ModelForm):
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class ComplaintThresholdForm(forms.ModelForm):
|
||||
"""Form for creating and editing complaint thresholds"""
|
||||
class ComplaintThresholdForm(HospitalFieldMixin, forms.ModelForm):
|
||||
"""
|
||||
Form for creating and editing complaint thresholds.
|
||||
|
||||
Hospital field visibility:
|
||||
- PX Admins: See dropdown with all hospitals
|
||||
- Others: Hidden field, auto-set to user's hospital
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = ComplaintThreshold
|
||||
@ -884,16 +907,6 @@ class ComplaintThresholdForm(forms.ModelForm):
|
||||
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
user = kwargs.pop('user', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Filter hospitals based on user role
|
||||
if user and not user.is_px_admin() and user.hospital:
|
||||
self.fields['hospital'].queryset = Hospital.objects.filter(id=user.hospital.id)
|
||||
self.fields['hospital'].initial = user.hospital
|
||||
self.fields['hospital'].widget.attrs['readonly'] = True
|
||||
|
||||
|
||||
|
||||
class PublicInquiryForm(forms.Form):
|
||||
|
||||
@ -68,7 +68,7 @@ class Command(BaseCommand):
|
||||
defaults={
|
||||
'sla_hours': 24,
|
||||
'first_reminder_hours_after': 12, # 12 hours from creation
|
||||
'second_reminder_hours_after': 30, # 12 + 18 hours from creation
|
||||
'second_reminder_hours_after': 18, # 18 hours from creation (6h before SLA)
|
||||
'escalation_hours_after': 24, # 24 hours from creation
|
||||
'is_active': True,
|
||||
}
|
||||
@ -87,7 +87,7 @@ class Command(BaseCommand):
|
||||
defaults={
|
||||
'sla_hours': 48,
|
||||
'first_reminder_hours_after': 24, # 24 hours from creation
|
||||
'second_reminder_hours_after': 60, # 24 + 36 hours from creation
|
||||
'second_reminder_hours_after': 36, # 36 hours from creation (12h before SLA)
|
||||
'escalation_hours_after': 48, # 48 hours from creation
|
||||
'is_active': True,
|
||||
}
|
||||
@ -115,7 +115,7 @@ class Command(BaseCommand):
|
||||
defaults={
|
||||
'sla_hours': 72,
|
||||
'first_reminder_hours_after': 24, # 24 hours from creation
|
||||
'second_reminder_hours_after': 72, # 24 + 48 hours from creation
|
||||
'second_reminder_hours_after': 48, # 48 hours from creation (24h before SLA)
|
||||
'escalation_hours_after': 72, # 72 hours from creation
|
||||
'is_active': True,
|
||||
}
|
||||
@ -140,7 +140,7 @@ class Command(BaseCommand):
|
||||
self.stdout.write('┌─────────────────────────────────────┬─────────┬──────────┬──────────┬────────────┐')
|
||||
self.stdout.write('│ Source │ SLA (h) │ 1st Rem │ 2nd Rem │ Escalation │')
|
||||
self.stdout.write('├─────────────────────────────────────┼─────────┼──────────┼──────────┼────────────┤')
|
||||
self.stdout.write('│ Ministry of Health │ 24 │ 12 │ 30 │ 24 │')
|
||||
self.stdout.write('│ Council of Cooperative Health Ins. │ 48 │ 24 │ 60 │ 48 │')
|
||||
self.stdout.write('│ Internal (Patient/Family/Staff) │ 72 │ 24 │ 72 │ 72 │')
|
||||
self.stdout.write('│ Ministry of Health │ 24 │ 12 │ 18 │ 24 │')
|
||||
self.stdout.write('│ Council of Cooperative Health Ins. │ 48 │ 24 │ 36 │ 48 │')
|
||||
self.stdout.write('│ Internal (Patient/Family/Staff) │ 72 │ 24 │ 48 │ 72 │')
|
||||
self.stdout.write('└─────────────────────────────────────┴─────────┴──────────┴──────────┴────────────┘')
|
||||
@ -369,6 +369,14 @@ class Complaint(UUIDModel, TimeStampedModel):
|
||||
)
|
||||
assigned_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# Activation tracking
|
||||
activated_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text="Timestamp when complaint was first activated (moved from OPEN to IN_PROGRESS)"
|
||||
)
|
||||
|
||||
# SLA tracking
|
||||
due_at = models.DateTimeField(db_index=True, help_text="SLA deadline")
|
||||
is_overdue = models.BooleanField(default=False, db_index=True)
|
||||
@ -625,6 +633,35 @@ class Complaint(UUIDModel, TimeStampedModel):
|
||||
"""Get AI-generated suggested action from metadata (deprecated, use suggested_action_en)"""
|
||||
return self.suggested_action_en
|
||||
|
||||
@property
|
||||
def suggested_actions(self):
|
||||
"""
|
||||
Get AI-generated suggested actions as a list from metadata.
|
||||
Returns list of dicts with structure:
|
||||
[
|
||||
{
|
||||
"action": "Action description",
|
||||
"priority": "high|medium|low",
|
||||
"category": "category_name"
|
||||
}
|
||||
]
|
||||
Falls back to single suggested_action_en if list is empty.
|
||||
"""
|
||||
if self.metadata and "ai_analysis" in self.metadata:
|
||||
actions = self.metadata["ai_analysis"].get("suggested_actions", [])
|
||||
# If list exists and has items, return it
|
||||
if actions and isinstance(actions, list):
|
||||
return actions
|
||||
# Fallback: convert old single action to list format
|
||||
single_action = self.metadata["ai_analysis"].get("suggested_action_en", "")
|
||||
if single_action:
|
||||
return [{
|
||||
"action": single_action,
|
||||
"priority": "medium",
|
||||
"category": "process_improvement"
|
||||
}]
|
||||
return []
|
||||
|
||||
@property
|
||||
def title_en(self):
|
||||
"""Get AI-generated title (English) from metadata"""
|
||||
@ -708,6 +745,9 @@ class Complaint(UUIDModel, TimeStampedModel):
|
||||
}
|
||||
return badge_map.get(self.emotion, "secondary")
|
||||
|
||||
@property
|
||||
def is_activated(self):
|
||||
return self.activated_at is not None
|
||||
def get_tracking_url(self):
|
||||
"""
|
||||
Get the public tracking URL for this complaint.
|
||||
@ -1950,6 +1990,7 @@ class OnCallAdminSchedule(UUIDModel, TimeStampedModel):
|
||||
bool: True if within working hours, False otherwise
|
||||
"""
|
||||
import pytz
|
||||
from datetime import time as datetime_time
|
||||
|
||||
if check_datetime is None:
|
||||
check_datetime = timezone.now()
|
||||
@ -1968,7 +2009,23 @@ class OnCallAdminSchedule(UUIDModel, TimeStampedModel):
|
||||
|
||||
# Check if it's within working hours
|
||||
current_time = local_time.time()
|
||||
return self.work_start_time <= current_time < self.work_end_time
|
||||
|
||||
# Ensure work_start_time and work_end_time are time objects
|
||||
start_time = self.work_start_time
|
||||
end_time = self.work_end_time
|
||||
|
||||
# Convert string to time if needed (handles string defaults from DB)
|
||||
if isinstance(start_time, str):
|
||||
parts = start_time.split(':')
|
||||
hours, minutes = int(parts[0]), int(parts[1])
|
||||
start_time = datetime_time(hours, minutes)
|
||||
|
||||
if isinstance(end_time, str):
|
||||
parts = end_time.split(':')
|
||||
hours, minutes = int(parts[0]), int(parts[1])
|
||||
end_time = datetime_time(hours, minutes)
|
||||
|
||||
return start_time <= current_time < end_time
|
||||
|
||||
|
||||
class OnCallAdmin(UUIDModel, TimeStampedModel):
|
||||
|
||||
@ -154,7 +154,7 @@ class ComplaintSerializer(serializers.ModelSerializer):
|
||||
'complaint_source_type', 'complaint_source_type_display',
|
||||
'source', 'source_name', 'source_code', 'status',
|
||||
'created_by', 'created_by_name',
|
||||
'assigned_to', 'assigned_to_name', 'assigned_at',
|
||||
'assigned_to', 'assigned_to_name', 'assigned_at', 'activated_at',
|
||||
'due_at', 'is_overdue', 'sla_status',
|
||||
'reminder_sent_at', 'escalated_at',
|
||||
'resolution', 'resolved_at', 'resolved_by',
|
||||
|
||||
@ -1303,6 +1303,9 @@ def analyze_complaint_with_ai(complaint_id):
|
||||
'title_ar': analysis.get('title_ar', ''),
|
||||
'short_description_en': analysis.get('short_description_en', ''),
|
||||
'short_description_ar': analysis.get('short_description_ar', ''),
|
||||
# Store suggested actions as list (new format)
|
||||
'suggested_actions': analysis.get('suggested_actions', []),
|
||||
# Keep single action fields for backward compatibility
|
||||
'suggested_action_en': analysis.get('suggested_action_en', ''),
|
||||
'suggested_action_ar': analysis.get('suggested_action_ar', ''),
|
||||
'reasoning_en': analysis.get('reasoning_en', ''),
|
||||
@ -2266,7 +2269,7 @@ def get_admins_to_notify(schedule, check_datetime=None, hospital=None):
|
||||
# For hospital-specific complaints, prefer admins assigned to that hospital
|
||||
# but also include system-wide admins
|
||||
px_admins = px_admins.filter(
|
||||
models.Q(hospital=hospital) | models.Q(hospital__isnull=True)
|
||||
Q(hospital=hospital) | Q(hospital__isnull=True)
|
||||
)
|
||||
|
||||
if is_working_hours:
|
||||
|
||||
@ -16,9 +16,9 @@ from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.core.services import AuditService
|
||||
from apps.core.decorators import hospital_admin_required, block_source_user
|
||||
from apps.organizations.models import Department, Hospital, Staff
|
||||
from apps.px_sources.models import SourceUser, PXSource
|
||||
from apps.px_sources.models import SourceUser, PXSource
|
||||
|
||||
from .models import (
|
||||
Complaint,
|
||||
@ -98,12 +98,20 @@ def complaint_list(request):
|
||||
|
||||
# Apply RBAC filters
|
||||
user = request.user
|
||||
# Get selected hospital for PX Admins (from middleware)
|
||||
selected_hospital = getattr(request, 'tenant_hospital', None)
|
||||
|
||||
if user.is_px_admin():
|
||||
pass # See all
|
||||
# PX Admins see all, but filter by selected hospital if set
|
||||
if selected_hospital:
|
||||
queryset = queryset.filter(hospital=selected_hospital)
|
||||
elif user.is_hospital_admin() and user.hospital:
|
||||
queryset = queryset.filter(hospital=user.hospital)
|
||||
elif user.is_department_manager() and user.department:
|
||||
queryset = queryset.filter(department=user.department)
|
||||
elif user.is_source_user():
|
||||
# Source Users can only see complaints they created
|
||||
queryset = queryset.filter(created_by=user)
|
||||
elif user.hospital:
|
||||
queryset = queryset.filter(hospital=user.hospital)
|
||||
else:
|
||||
@ -495,14 +503,18 @@ def complaint_create(request):
|
||||
try:
|
||||
# Create complaint with AI defaults
|
||||
complaint = form.save(commit=False)
|
||||
|
||||
|
||||
# Set AI-determined defaults
|
||||
complaint.title = 'Complaint' # AI will generate title
|
||||
# category can be None, AI will determine it
|
||||
complaint.subcategory = '' # AI will determine
|
||||
|
||||
# Set source from logged-in source user
|
||||
if source_user and source_user.source:
|
||||
|
||||
# Set source from form if selected, otherwise from logged-in source user
|
||||
if form.cleaned_data.get('source'):
|
||||
# User explicitly selected a PX source
|
||||
complaint.source = form.cleaned_data['source']
|
||||
elif source_user and source_user.source:
|
||||
# Source user is submitting (auto-assign their source)
|
||||
complaint.source = source_user.source
|
||||
else:
|
||||
# Fallback: get or create a 'staff' source
|
||||
@ -516,7 +528,7 @@ def complaint_create(request):
|
||||
description='Complaints submitted by staff members'
|
||||
)
|
||||
complaint.source = source_obj
|
||||
|
||||
|
||||
complaint.priority = 'medium' # AI will update
|
||||
complaint.severity = 'medium' # AI will update
|
||||
complaint.created_by = request.user
|
||||
@ -594,10 +606,10 @@ def complaint_create(request):
|
||||
return render(request, "complaints/complaint_form.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
@hospital_admin_required
|
||||
@require_http_methods(["POST"])
|
||||
def complaint_assign(request, pk):
|
||||
"""Assign complaint to user"""
|
||||
"""Assign complaint to user - Admin only"""
|
||||
complaint = get_object_or_404(Complaint, pk=pk)
|
||||
|
||||
# Check if complaint is in active status
|
||||
@ -810,7 +822,7 @@ def complaint_change_department(request, pk):
|
||||
@login_required
|
||||
@require_http_methods(["POST"])
|
||||
def complaint_escalate(request, pk):
|
||||
"""Escalate complaint to selected staff (default is staff's manager)"""
|
||||
"""Escalate complaint to selected staff (default is staff's manager) - Admin and Dept Manager only"""
|
||||
complaint = get_object_or_404(Complaint, pk=pk)
|
||||
|
||||
# Check if complaint is in active status
|
||||
@ -960,7 +972,14 @@ def complaint_activate(request, pk):
|
||||
# Assign to current user
|
||||
complaint.assigned_to = user
|
||||
complaint.assigned_at = timezone.now()
|
||||
complaint.save(update_fields=["assigned_to", "assigned_at"])
|
||||
|
||||
# Set activated_at if this is the first activation (status is OPEN)
|
||||
if complaint.status == ComplaintStatus.OPEN:
|
||||
complaint.status = ComplaintStatus.IN_PROGRESS
|
||||
complaint.activated_at = timezone.now()
|
||||
complaint.save(update_fields=["assigned_to", "assigned_at", "status", "activated_at"])
|
||||
else:
|
||||
complaint.save(update_fields=["assigned_to", "assigned_at"])
|
||||
|
||||
# Create update
|
||||
assign_message = f"Complaint activated and assigned to {user.get_full_name()}"
|
||||
@ -1117,17 +1136,13 @@ def complaint_export_excel(request):
|
||||
return export_complaints_excel(queryset, request.GET.dict())
|
||||
|
||||
|
||||
@login_required
|
||||
@hospital_admin_required
|
||||
@require_http_methods(["POST"])
|
||||
def complaint_bulk_assign(request):
|
||||
"""Bulk assign complaints"""
|
||||
"""Bulk assign complaints - Admin only"""
|
||||
from apps.complaints.utils import bulk_assign_complaints
|
||||
import json
|
||||
|
||||
# Check permission
|
||||
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
|
||||
return JsonResponse({"success": False, "error": "Permission denied"}, status=403)
|
||||
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
complaint_ids = data.get("complaint_ids", [])
|
||||
@ -1149,17 +1164,13 @@ def complaint_bulk_assign(request):
|
||||
return JsonResponse({"success": False, "error": str(e)}, status=500)
|
||||
|
||||
|
||||
@login_required
|
||||
@hospital_admin_required
|
||||
@require_http_methods(["POST"])
|
||||
def complaint_bulk_status(request):
|
||||
"""Bulk change complaint status"""
|
||||
"""Bulk change complaint status - Admin only"""
|
||||
from apps.complaints.utils import bulk_change_status
|
||||
import json
|
||||
|
||||
# Check permission
|
||||
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
|
||||
return JsonResponse({"success": False, "error": "Permission denied"}, status=403)
|
||||
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
complaint_ids = data.get("complaint_ids", [])
|
||||
@ -1182,17 +1193,13 @@ def complaint_bulk_status(request):
|
||||
return JsonResponse({"success": False, "error": str(e)}, status=500)
|
||||
|
||||
|
||||
@login_required
|
||||
@hospital_admin_required
|
||||
@require_http_methods(["POST"])
|
||||
def complaint_bulk_escalate(request):
|
||||
"""Bulk escalate complaints"""
|
||||
"""Bulk escalate complaints - Admin only"""
|
||||
from apps.complaints.utils import bulk_escalate_complaints
|
||||
import json
|
||||
|
||||
# Check permission
|
||||
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
|
||||
return JsonResponse({"success": False, "error": "Permission denied"}, status=403)
|
||||
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
complaint_ids = data.get("complaint_ids", [])
|
||||
@ -1231,12 +1238,20 @@ def inquiry_list(request):
|
||||
|
||||
# Apply RBAC filters
|
||||
user = request.user
|
||||
# Get selected hospital for PX Admins (from middleware)
|
||||
selected_hospital = getattr(request, 'tenant_hospital', None)
|
||||
|
||||
if user.is_px_admin():
|
||||
pass # See all
|
||||
# PX Admins see all, but filter by selected hospital if set
|
||||
if selected_hospital:
|
||||
queryset = queryset.filter(hospital=selected_hospital)
|
||||
elif user.is_hospital_admin() and user.hospital:
|
||||
queryset = queryset.filter(hospital=user.hospital)
|
||||
elif user.is_department_manager() and user.department:
|
||||
queryset = queryset.filter(department=user.department)
|
||||
elif user.is_source_user():
|
||||
# Source Users can only see inquiries they created
|
||||
queryset = queryset.filter(created_by=user)
|
||||
elif user.hospital:
|
||||
queryset = queryset.filter(hospital=user.hospital)
|
||||
else:
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
"""
|
||||
Complaints views and viewsets
|
||||
"""
|
||||
import logging
|
||||
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
@ -31,6 +33,9 @@ from .serializers import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def map_complaint_category_to_action_category(complaint_category_code):
|
||||
"""
|
||||
Map complaint category code to PX Action category.
|
||||
|
||||
@ -665,12 +665,16 @@ class AIService:
|
||||
7. Return ALL staff names WITHOUT titles (Dr., Nurse, دكتور, ممرض, etc.)
|
||||
8. Identify the PRIMARY staff member (the one most relevant to the complaint)
|
||||
9. If no staff is mentioned, return empty arrays for staff names
|
||||
10. Generate a suggested_action (2-3 sentences) with specific, actionable steps to address this complaint in BOTH English and Arabic
|
||||
10. Generate 3-5 suggested_actions as a JSON list, each with:
|
||||
- action: Specific, actionable step
|
||||
- priority: high|medium|low
|
||||
- category: clinical_quality|patient_safety|service_quality|staff_behavior|facility|process_improvement|other
|
||||
Provide all actions in BOTH English and Arabic
|
||||
|
||||
IMPORTANT: ALL TEXT FIELDS MUST BE PROVIDED IN BOTH ENGLISH AND ARABIC
|
||||
- title: Provide in both English and Arabic
|
||||
- short_description: Provide in both English and Arabic
|
||||
- suggested_action: Provide in both English and Arabic
|
||||
- suggested_actions: Provide as a list with English and Arabic for each action
|
||||
- reasoning: Provide in both English and Arabic
|
||||
|
||||
Provide your analysis in JSON format:
|
||||
@ -686,8 +690,20 @@ class AIService:
|
||||
"department": "exact department name from the hospital's departments, or empty string if not applicable",
|
||||
"staff_names": ["name1", "name2", "name3"],
|
||||
"primary_staff_name": "name of PRIMARY staff member (the one most relevant to the complaint), or empty string if no staff mentioned",
|
||||
"suggested_action_en": "2-3 specific, actionable steps in English to address this complaint",
|
||||
"suggested_action_ar": "خطوات محددة وعمليه بالعربية",
|
||||
"suggested_actions": [
|
||||
{{
|
||||
"action_en": "Specific actionable step in English",
|
||||
"action_ar": "خطوة محددة بالعربية",
|
||||
"priority": "high|medium|low",
|
||||
"category": "clinical_quality|patient_safety|service_quality|staff_behavior|facility|process_improvement|other"
|
||||
}},
|
||||
{{
|
||||
"action_en": "Another action in English",
|
||||
"action_ar": "إجراء آخر بالعربية",
|
||||
"priority": "medium",
|
||||
"category": "process_improvement"
|
||||
}}
|
||||
],
|
||||
"reasoning_en": "Brief explanation in English of your classification (2-3 sentences)",
|
||||
"reasoning_ar": "شرح مختصر بالعربية",
|
||||
"taxonomy": {{
|
||||
|
||||
@ -9,17 +9,12 @@ from apps.organizations.models import Hospital
|
||||
from apps.px_action_center.models import PXActionSLAConfig, RoutingRule
|
||||
from apps.complaints.models import OnCallAdminSchedule
|
||||
from apps.callcenter.models import CallRecord
|
||||
from apps.core.decorators import px_admin_required
|
||||
|
||||
|
||||
@login_required
|
||||
@px_admin_required
|
||||
def config_dashboard(request):
|
||||
"""Configuration dashboard - overview of system settings"""
|
||||
# Only PX Admins can access
|
||||
if not request.user.is_px_admin():
|
||||
from django.contrib import messages
|
||||
from django.shortcuts import redirect
|
||||
messages.error(request, "Only PX Admins can access configuration.")
|
||||
return redirect('dashboard:command-center')
|
||||
"""Configuration dashboard - overview of system settings - PX Admin only"""
|
||||
|
||||
# Get counts
|
||||
sla_configs_count = PXActionSLAConfig.objects.filter(is_active=True).count()
|
||||
@ -39,15 +34,9 @@ def config_dashboard(request):
|
||||
return render(request, 'config/dashboard.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
@px_admin_required
|
||||
def sla_config_list(request):
|
||||
"""SLA configurations list view"""
|
||||
# Only PX Admins can access
|
||||
if not request.user.is_px_admin():
|
||||
from django.contrib import messages
|
||||
from django.shortcuts import redirect
|
||||
messages.error(request, "Only PX Admins can access configuration.")
|
||||
return redirect('dashboard:command-center')
|
||||
"""SLA configurations list view - PX Admin only"""
|
||||
|
||||
queryset = PXActionSLAConfig.objects.select_related('hospital', 'department')
|
||||
|
||||
@ -84,15 +73,9 @@ def sla_config_list(request):
|
||||
return render(request, 'config/sla_config.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
@px_admin_required
|
||||
def routing_rules_list(request):
|
||||
"""Routing rules list view"""
|
||||
# Only PX Admins can access
|
||||
if not request.user.is_px_admin():
|
||||
from django.contrib import messages
|
||||
from django.shortcuts import redirect
|
||||
messages.error(request, "Only PX Admins can access configuration.")
|
||||
return redirect('dashboard:command-center')
|
||||
"""Routing rules list view - PX Admin only"""
|
||||
|
||||
queryset = RoutingRule.objects.select_related(
|
||||
'hospital', 'department', 'assign_to_user', 'assign_to_department'
|
||||
|
||||
@ -23,6 +23,21 @@ def sidebar_counts(request):
|
||||
|
||||
user = request.user
|
||||
|
||||
# Source Users only see their own created complaints
|
||||
if user.is_source_user():
|
||||
complaint_count = Complaint.objects.filter(
|
||||
created_by=user,
|
||||
status__in=['open', 'in_progress']
|
||||
).count()
|
||||
return {
|
||||
'complaint_count': complaint_count,
|
||||
'feedback_count': 0,
|
||||
'action_count': 0,
|
||||
'current_hospital': None,
|
||||
'is_px_admin': False,
|
||||
'is_source_user': True,
|
||||
}
|
||||
|
||||
# Filter based on user role and tenant_hospital
|
||||
if user.is_px_admin():
|
||||
# PX Admins use their selected hospital from session
|
||||
@ -75,6 +90,7 @@ def sidebar_counts(request):
|
||||
'action_count': action_count,
|
||||
'current_hospital': getattr(request, 'tenant_hospital', None),
|
||||
'is_px_admin': request.user.is_authenticated and request.user.is_px_admin(),
|
||||
'is_source_user': False,
|
||||
}
|
||||
|
||||
|
||||
@ -88,9 +104,24 @@ def hospital_context(request):
|
||||
return {}
|
||||
|
||||
hospital = getattr(request, 'tenant_hospital', None)
|
||||
|
||||
# Get list of hospitals for PX Admin switcher
|
||||
hospitals_list = []
|
||||
if request.user.is_px_admin():
|
||||
from apps.organizations.models import Hospital
|
||||
hospitals_list = list(
|
||||
Hospital.objects.filter(status='active').order_by('name').values('id', 'name', 'code')
|
||||
)
|
||||
|
||||
# Source user context
|
||||
is_source_user = request.user.is_source_user()
|
||||
source_user_profile = getattr(request, 'source_user_profile', None)
|
||||
|
||||
return {
|
||||
'current_hospital': hospital,
|
||||
'is_px_admin': request.user.is_px_admin(),
|
||||
'is_source_user': is_source_user,
|
||||
'source_user_profile': source_user_profile,
|
||||
'hospitals_list': hospitals_list,
|
||||
# 'provisional_user_count': provisional_user_count,
|
||||
}
|
||||
|
||||
236
apps/core/decorators.py
Normal file
236
apps/core/decorators.py
Normal file
@ -0,0 +1,236 @@
|
||||
"""
|
||||
Role-based access control decorators for PX360.
|
||||
|
||||
Provides decorators to restrict views based on user roles:
|
||||
- PX Admin
|
||||
- Hospital Admin
|
||||
- Department Manager
|
||||
- PX Coordinator
|
||||
- Source User
|
||||
"""
|
||||
from functools import wraps
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.shortcuts import redirect
|
||||
from django.contrib import messages
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
def px_admin_required(view_func):
|
||||
"""
|
||||
Decorator to restrict access to PX Admins only.
|
||||
|
||||
Example:
|
||||
@px_admin_required
|
||||
def system_settings(request):
|
||||
# Only PX Admins can access this
|
||||
"""
|
||||
@wraps(view_func)
|
||||
@login_required
|
||||
def _wrapped_view(request, *args, **kwargs):
|
||||
if not request.user.is_px_admin():
|
||||
messages.error(
|
||||
request,
|
||||
_("Access denied. PX Admin privileges required.")
|
||||
)
|
||||
return redirect('analytics:command_center')
|
||||
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
return _wrapped_view
|
||||
|
||||
|
||||
def hospital_admin_required(view_func):
|
||||
"""
|
||||
Decorator to restrict access to Hospital Admins and PX Admins.
|
||||
|
||||
Example:
|
||||
@hospital_admin_required
|
||||
def hospital_settings(request):
|
||||
# Only Hospital Admins and PX Admins can access this
|
||||
"""
|
||||
@wraps(view_func)
|
||||
@login_required
|
||||
def _wrapped_view(request, *args, **kwargs):
|
||||
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
|
||||
messages.error(
|
||||
request,
|
||||
_("Access denied. Hospital Admin privileges required.")
|
||||
)
|
||||
return redirect('analytics:command_center')
|
||||
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
return _wrapped_view
|
||||
|
||||
|
||||
def admin_required(view_func):
|
||||
"""
|
||||
Decorator to restrict access to any Admin (PX, Hospital, or Dept Manager).
|
||||
|
||||
Example:
|
||||
@admin_required
|
||||
def management_view(request):
|
||||
# Any admin can access this
|
||||
"""
|
||||
@wraps(view_func)
|
||||
@login_required
|
||||
def _wrapped_view(request, *args, **kwargs):
|
||||
if not (request.user.is_px_admin() or
|
||||
request.user.is_hospital_admin() or
|
||||
request.user.is_department_manager()):
|
||||
messages.error(
|
||||
request,
|
||||
_("Access denied. Admin privileges required.")
|
||||
)
|
||||
return redirect('analytics:command_center')
|
||||
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
return _wrapped_view
|
||||
|
||||
|
||||
def px_coordinator_required(view_func):
|
||||
"""
|
||||
Decorator to restrict access to PX Coordinators and above.
|
||||
|
||||
Allows: PX Admin, Hospital Admin, Department Manager, PX Coordinator
|
||||
|
||||
Example:
|
||||
@px_coordinator_required
|
||||
def complaint_management(request):
|
||||
# Coordinators and admins can access this
|
||||
"""
|
||||
@wraps(view_func)
|
||||
@login_required
|
||||
def _wrapped_view(request, *args, **kwargs):
|
||||
user = request.user
|
||||
if not (user.is_px_admin() or
|
||||
user.is_hospital_admin() or
|
||||
user.is_department_manager() or
|
||||
user.has_role('PX Coordinator')):
|
||||
messages.error(
|
||||
request,
|
||||
_("Access denied. PX Coordinator privileges required.")
|
||||
)
|
||||
return redirect('analytics:command_center')
|
||||
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
return _wrapped_view
|
||||
|
||||
|
||||
def staff_required(view_func):
|
||||
"""
|
||||
Decorator to restrict access to Hospital Staff (not Source Users).
|
||||
|
||||
Allows all authenticated users except Source Users.
|
||||
|
||||
Example:
|
||||
@staff_required
|
||||
def internal_tool(request):
|
||||
# Any hospital staff can access, but not source users
|
||||
"""
|
||||
@wraps(view_func)
|
||||
@login_required
|
||||
def _wrapped_view(request, *args, **kwargs):
|
||||
if request.user.is_source_user():
|
||||
messages.error(
|
||||
request,
|
||||
_("Access denied. This page is not available for source users.")
|
||||
)
|
||||
return redirect('px_sources:source_user_dashboard')
|
||||
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
return _wrapped_view
|
||||
|
||||
|
||||
def source_user_required(view_func):
|
||||
"""
|
||||
Decorator to restrict access to Source Users only.
|
||||
|
||||
Example:
|
||||
@source_user_required
|
||||
def source_user_dashboard(request):
|
||||
# Only source users can access this
|
||||
"""
|
||||
@wraps(view_func)
|
||||
@login_required
|
||||
def _wrapped_view(request, *args, **kwargs):
|
||||
if not request.user.is_source_user():
|
||||
raise PermissionDenied(
|
||||
_("Access denied. Source user privileges required.")
|
||||
)
|
||||
|
||||
# Get source user profile
|
||||
profile = request.user.get_source_user_profile_active()
|
||||
if not profile:
|
||||
messages.error(
|
||||
request,
|
||||
_("Your source user account is inactive. Please contact your administrator.")
|
||||
)
|
||||
return redirect('accounts:login')
|
||||
|
||||
# Store in request for easy access
|
||||
request.source_user = profile
|
||||
request.source = profile.source
|
||||
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
return _wrapped_view
|
||||
|
||||
|
||||
def block_source_user(view_func):
|
||||
"""
|
||||
Decorator to BLOCK source users from accessing admin/staff pages.
|
||||
Redirects source users to their dashboard instead.
|
||||
|
||||
Example:
|
||||
@block_source_user
|
||||
def staff_management(request):
|
||||
# Source users CANNOT access this
|
||||
"""
|
||||
@wraps(view_func)
|
||||
@login_required
|
||||
def _wrapped_view(request, *args, **kwargs):
|
||||
if request.user.is_source_user():
|
||||
# Silently redirect to source user dashboard
|
||||
return redirect('px_sources:source_user_dashboard')
|
||||
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
return _wrapped_view
|
||||
|
||||
|
||||
def source_user_or_admin(view_func):
|
||||
"""
|
||||
Decorator that allows both source users AND admins.
|
||||
|
||||
Example:
|
||||
@source_user_or_admin
|
||||
def complaint_detail(request, pk):
|
||||
# Both source users and admins can view
|
||||
"""
|
||||
@wraps(view_func)
|
||||
@login_required
|
||||
def _wrapped_view(request, *args, **kwargs):
|
||||
user = request.user
|
||||
|
||||
# Allow admins
|
||||
if (user.is_px_admin() or
|
||||
user.is_hospital_admin() or
|
||||
user.is_department_manager()):
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
# Allow active source users
|
||||
if user.is_source_user():
|
||||
profile = user.get_source_user_profile_active()
|
||||
if profile:
|
||||
request.source_user = profile
|
||||
request.source = profile.source
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
raise PermissionDenied(_("Access denied."))
|
||||
|
||||
return _wrapped_view
|
||||
137
apps/core/form_mixins.py
Normal file
137
apps/core/form_mixins.py
Normal file
@ -0,0 +1,137 @@
|
||||
"""
|
||||
Form mixins for tenant-aware forms.
|
||||
|
||||
Provides mixins to handle hospital field visibility based on user role.
|
||||
"""
|
||||
from django import forms
|
||||
from apps.organizations.models import Hospital
|
||||
|
||||
|
||||
class HospitalFieldMixin:
|
||||
"""
|
||||
Mixin to handle hospital field visibility based on user role.
|
||||
|
||||
- PX Admins: See dropdown with all active hospitals
|
||||
- Others: Hidden field, auto-set to user's hospital
|
||||
|
||||
Usage:
|
||||
class MyForm(HospitalFieldMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = MyModel
|
||||
fields = ['hospital', ...]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Hospital field is automatically configured
|
||||
|
||||
In views:
|
||||
form = MyForm(user=request.user)
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.user = kwargs.pop('user', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if self.user and 'hospital' in self.fields:
|
||||
self._setup_hospital_field()
|
||||
|
||||
def _setup_hospital_field(self):
|
||||
"""Configure hospital field based on user role."""
|
||||
hospital_field = self.fields['hospital']
|
||||
|
||||
if self.user.is_px_admin():
|
||||
# PX Admin: Show dropdown with all active hospitals
|
||||
hospital_field.queryset = Hospital.objects.filter(status='active').order_by('name')
|
||||
hospital_field.required = True
|
||||
# Update widget attrs instead of replacing widget to preserve choices
|
||||
hospital_field.widget.attrs.update({
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
|
||||
})
|
||||
else:
|
||||
# Regular user: Hide field and set default
|
||||
hospital_field.widget = forms.HiddenInput()
|
||||
hospital_field.required = False
|
||||
|
||||
# Set initial value to user's hospital
|
||||
if self.user.hospital:
|
||||
hospital_field.initial = self.user.hospital
|
||||
# Limit queryset to just user's hospital (for validation)
|
||||
hospital_field.queryset = Hospital.objects.filter(id=self.user.hospital.id)
|
||||
else:
|
||||
# User has no hospital - empty queryset
|
||||
hospital_field.queryset = Hospital.objects.none()
|
||||
|
||||
def clean_hospital(self):
|
||||
"""
|
||||
Ensure non-PX admins can only use their own hospital.
|
||||
PX Admins can select any hospital.
|
||||
"""
|
||||
hospital = self.cleaned_data.get('hospital')
|
||||
|
||||
if not self.user:
|
||||
return hospital
|
||||
|
||||
if self.user.is_px_admin():
|
||||
# PX Admin must select a hospital
|
||||
if not hospital:
|
||||
raise forms.ValidationError("Please select a hospital.")
|
||||
return hospital
|
||||
else:
|
||||
# Non-PX admins: Force user's hospital
|
||||
if self.user.hospital:
|
||||
return self.user.hospital
|
||||
else:
|
||||
raise forms.ValidationError(
|
||||
"You do not have a hospital assigned. Please contact your administrator."
|
||||
)
|
||||
|
||||
|
||||
class DepartmentFieldMixin:
|
||||
"""
|
||||
Mixin to handle department field filtering based on user's hospital.
|
||||
|
||||
- Filters departments to only show those in the selected/current hospital
|
||||
- Works with HospitalFieldMixin
|
||||
|
||||
Usage:
|
||||
class MyForm(HospitalFieldMixin, DepartmentFieldMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = MyModel
|
||||
fields = ['hospital', 'department', ...]
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if 'department' in self.fields:
|
||||
self._setup_department_field()
|
||||
|
||||
def _setup_department_field(self):
|
||||
"""Configure department field with hospital-based filtering."""
|
||||
from apps.organizations.models import Department
|
||||
|
||||
department_field = self.fields['department']
|
||||
|
||||
# Get the hospital (either from form data or user's hospital)
|
||||
hospital = None
|
||||
if self.data.get('hospital'):
|
||||
try:
|
||||
hospital = Hospital.objects.get(id=self.data['hospital'])
|
||||
except Hospital.DoesNotExist:
|
||||
pass
|
||||
elif self.initial.get('hospital'):
|
||||
hospital = self.initial['hospital']
|
||||
elif self.instance and self.instance.pk and self.instance.hospital:
|
||||
hospital = self.instance.hospital
|
||||
elif self.user and self.user.hospital:
|
||||
hospital = self.user.hospital
|
||||
|
||||
if hospital:
|
||||
# Filter departments to user's hospital
|
||||
department_field.queryset = Department.objects.filter(
|
||||
hospital=hospital,
|
||||
status='active'
|
||||
).order_by('name')
|
||||
else:
|
||||
# No hospital context - empty queryset
|
||||
department_field.queryset = Department.objects.none()
|
||||
@ -11,6 +11,7 @@ class TenantMiddleware(MiddlewareMixin):
|
||||
This middleware ensures that:
|
||||
- authenticated users have their tenant_hospital set from their profile
|
||||
- PX admins can switch between hospitals via session
|
||||
- Source Users have their source context available
|
||||
- All requests have tenant context available
|
||||
"""
|
||||
|
||||
@ -20,6 +21,15 @@ class TenantMiddleware(MiddlewareMixin):
|
||||
# Store user's role for quick access
|
||||
request.user_roles = request.user.get_role_names()
|
||||
|
||||
# Set source user context
|
||||
request.source_user = None
|
||||
request.source_user_profile = None
|
||||
if request.user.is_source_user():
|
||||
profile = request.user.get_source_user_profile_active()
|
||||
if profile:
|
||||
request.source_user = profile
|
||||
request.source_user_profile = profile
|
||||
|
||||
# PX Admins can switch hospitals via session
|
||||
if request.user.is_px_admin():
|
||||
hospital_id = request.session.get('selected_hospital_id')
|
||||
@ -42,5 +52,7 @@ class TenantMiddleware(MiddlewareMixin):
|
||||
else:
|
||||
request.tenant_hospital = None
|
||||
request.user_roles = []
|
||||
request.source_user = None
|
||||
request.source_user_profile = None
|
||||
|
||||
return None
|
||||
|
||||
@ -17,11 +17,20 @@ class TenantAccessMixin:
|
||||
- PX admins can access all hospitals
|
||||
- Hospital admins can only access their hospital
|
||||
- Department managers can only access their department
|
||||
- Source Users can only access their own created data
|
||||
"""
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
"""Retrieve object with tenant validation."""
|
||||
obj = super().get_object(queryset)
|
||||
user = self.request.user
|
||||
|
||||
# Source Users can only access their own created data
|
||||
if user.is_source_user():
|
||||
if hasattr(obj, 'created_by'):
|
||||
if obj.created_by != user:
|
||||
raise PermissionDenied("You can only access data you created")
|
||||
return obj
|
||||
|
||||
# Check if user has access to this object's hospital
|
||||
if hasattr(obj, 'hospital'):
|
||||
@ -35,6 +44,12 @@ class TenantAccessMixin:
|
||||
queryset = super().get_queryset()
|
||||
user = self.request.user
|
||||
|
||||
# Source Users can only see their own created data
|
||||
if user.is_source_user():
|
||||
if hasattr(queryset.model, 'created_by'):
|
||||
return queryset.filter(created_by=user)
|
||||
return queryset.none()
|
||||
|
||||
# PX Admins can see all hospitals
|
||||
if user.is_px_admin():
|
||||
return queryset
|
||||
|
||||
@ -6,6 +6,64 @@ from django import template
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def replace(value, arg):
|
||||
"""
|
||||
Replace occurrences of a substring with another substring.
|
||||
|
||||
Usage: {{ value|replace:"old:new" }}
|
||||
Example: {{ "hello_world"|replace:"_":" " }} => "hello world"
|
||||
"""
|
||||
if isinstance(value, str) and isinstance(arg, str):
|
||||
try:
|
||||
old, new = arg.split(':', 1)
|
||||
return value.replace(old, new)
|
||||
except ValueError:
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
@register.filter
|
||||
def lookup(dictionary, key):
|
||||
"""
|
||||
Lookup a value from a dictionary by key.
|
||||
|
||||
Usage: {{ my_dict|lookup:key }}
|
||||
Example: {{ row|lookup:"column_name" }}
|
||||
"""
|
||||
if isinstance(dictionary, dict):
|
||||
return dictionary.get(key)
|
||||
return None
|
||||
|
||||
|
||||
@register.filter
|
||||
def div(value, arg):
|
||||
"""
|
||||
Divide value by arg.
|
||||
|
||||
Usage: {{ value|div:arg }}
|
||||
Example: {{ 10|div:2 }} => 5.0
|
||||
"""
|
||||
try:
|
||||
return float(value) / float(arg)
|
||||
except (ValueError, ZeroDivisionError, TypeError):
|
||||
return 0
|
||||
|
||||
|
||||
@register.filter
|
||||
def mul(value, arg):
|
||||
"""
|
||||
Multiply value by arg.
|
||||
|
||||
Usage: {{ value|mul:arg }}
|
||||
Example: {{ 5|mul:3 }} => 15.0
|
||||
"""
|
||||
try:
|
||||
return float(value) * float(arg)
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def get_all_hospitals():
|
||||
"""
|
||||
|
||||
@ -6,6 +6,7 @@ from django.urls import path
|
||||
from .views import (
|
||||
health_check,
|
||||
select_hospital,
|
||||
switch_hospital,
|
||||
no_hospital_assigned,
|
||||
public_submit_landing,
|
||||
public_inquiry_submit,
|
||||
@ -24,6 +25,7 @@ urlpatterns = [
|
||||
|
||||
# Hospital selection
|
||||
path('select-hospital/', select_hospital, name='select_hospital'),
|
||||
path('switch-hospital/', switch_hospital, name='switch_hospital'),
|
||||
path('no-hospital/', no_hospital_assigned, name='no_hospital_assigned'),
|
||||
|
||||
# Public submission pages
|
||||
|
||||
@ -82,6 +82,50 @@ def select_hospital(request):
|
||||
return render(request, 'core/select_hospital.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def switch_hospital(request):
|
||||
"""
|
||||
AJAX endpoint to switch hospitals for PX Admins.
|
||||
|
||||
Stores selected hospital in session and returns JSON response.
|
||||
"""
|
||||
# Only PX Admins can switch hospitals
|
||||
if not request.user.is_px_admin():
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Permission denied. Only PX Admins can switch hospitals.'
|
||||
}, status=403)
|
||||
|
||||
from apps.organizations.models import Hospital
|
||||
|
||||
hospital_id = request.POST.get('hospital_id')
|
||||
|
||||
if not hospital_id:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Hospital ID is required'
|
||||
}, status=400)
|
||||
|
||||
try:
|
||||
hospital = Hospital.objects.get(id=hospital_id)
|
||||
request.session['selected_hospital_id'] = str(hospital.id)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'hospital': {
|
||||
'id': str(hospital.id),
|
||||
'name': hospital.name,
|
||||
'code': hospital.code,
|
||||
}
|
||||
})
|
||||
except Hospital.DoesNotExist:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Hospital not found'
|
||||
}, status=404)
|
||||
|
||||
|
||||
@login_required
|
||||
def no_hospital_assigned(request):
|
||||
"""
|
||||
|
||||
@ -8,13 +8,14 @@ from .views import (
|
||||
admin_evaluation, admin_evaluation_chart_data,
|
||||
staff_performance_detail, staff_performance_trends,
|
||||
department_benchmarks, export_staff_performance,
|
||||
performance_analytics_api
|
||||
performance_analytics_api, command_center_api
|
||||
)
|
||||
|
||||
app_name = 'dashboard'
|
||||
|
||||
urlpatterns = [
|
||||
path('', CommandCenterView.as_view(), name='command-center'),
|
||||
path('api/', command_center_api, name='command_center_api'),
|
||||
path('my/', my_dashboard, name='my_dashboard'),
|
||||
path('bulk-action/', dashboard_bulk_action, name='bulk_action'),
|
||||
|
||||
|
||||
@ -9,10 +9,11 @@ from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import Avg, Count, Q, Sum
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.shortcuts import redirect, render, reverse
|
||||
from django.utils import timezone
|
||||
from django.views.generic import TemplateView
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.contrib import messages
|
||||
|
||||
|
||||
|
||||
@ -21,16 +22,25 @@ class CommandCenterView(LoginRequiredMixin, TemplateView):
|
||||
PX Command Center Dashboard - Real-time control panel.
|
||||
|
||||
Shows:
|
||||
- Top KPI cards (complaints, actions, surveys, etc.)
|
||||
- Red Alert Banner (urgent items requiring immediate attention)
|
||||
- Top KPI cards (complaints, actions, surveys, etc.) with drill-down
|
||||
- Charts (trends, satisfaction, leaderboards)
|
||||
- Live feed (latest complaints, actions, events)
|
||||
- Enhanced modules (Inquiries, Observations)
|
||||
- Filters (date range, hospital, department)
|
||||
|
||||
Follows the "5-Second Rule": Critical signals dominant and comprehensible within 5 seconds.
|
||||
Uses modular tile/card system with 30-60 second auto-refresh capability.
|
||||
"""
|
||||
template_name = 'dashboard/command_center.html'
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
"""Check PX Admin has selected a hospital before processing request"""
|
||||
# Only check hospital selection for authenticated users
|
||||
"""Check user type and redirect accordingly"""
|
||||
# Redirect Source Users to their dashboard
|
||||
if request.user.is_authenticated and request.user.is_source_user():
|
||||
return redirect('px_sources:source_user_dashboard')
|
||||
|
||||
# Check PX Admin has selected a hospital before processing request
|
||||
if request.user.is_authenticated and request.user.is_px_admin() and not request.tenant_hospital:
|
||||
return redirect('core:select_hospital')
|
||||
|
||||
@ -41,7 +51,7 @@ class CommandCenterView(LoginRequiredMixin, TemplateView):
|
||||
user = self.request.user
|
||||
|
||||
# Import models
|
||||
from apps.complaints.models import Complaint
|
||||
from apps.complaints.models import Complaint, Inquiry
|
||||
from apps.px_action_center.models import PXAction
|
||||
from apps.surveys.models import SurveyInstance
|
||||
from apps.social.models import SocialMediaComment
|
||||
@ -49,97 +59,330 @@ class CommandCenterView(LoginRequiredMixin, TemplateView):
|
||||
from apps.integrations.models import InboundEvent
|
||||
from apps.physicians.models import PhysicianMonthlyRating
|
||||
from apps.organizations.models import Staff
|
||||
from apps.observations.models import Observation
|
||||
|
||||
# Date filters
|
||||
now = timezone.now()
|
||||
last_24h = now - timedelta(hours=24)
|
||||
last_7d = now - timedelta(days=7)
|
||||
last_30d = now - timedelta(days=30)
|
||||
last_60d = now - timedelta(days=60)
|
||||
|
||||
# Base querysets (filtered by user role and tenant_hospital)
|
||||
if user.is_px_admin():
|
||||
# PX Admins use their selected hospital from session
|
||||
hospital = self.request.tenant_hospital
|
||||
complaints_qs = Complaint.objects.filter(hospital=hospital) if hospital else Complaint.objects.none()
|
||||
inquiries_qs = Inquiry.objects.filter(hospital=hospital) if hospital else Inquiry.objects.none()
|
||||
actions_qs = PXAction.objects.filter(hospital=hospital) if hospital else PXAction.objects.none()
|
||||
surveys_qs = SurveyInstance.objects.all() # Surveys can be viewed across hospitals
|
||||
social_qs = SocialMediaComment.objects.all() # Social media is organization-wide, not hospital-specific
|
||||
calls_qs = CallCenterInteraction.objects.filter(hospital=hospital) if hospital else CallCenterInteraction.objects.none()
|
||||
observations_qs = Observation.objects.filter(hospital=hospital) if hospital else Observation.objects.none()
|
||||
elif user.is_hospital_admin() and user.hospital:
|
||||
complaints_qs = Complaint.objects.filter(hospital=user.hospital)
|
||||
inquiries_qs = Inquiry.objects.filter(hospital=user.hospital)
|
||||
actions_qs = PXAction.objects.filter(hospital=user.hospital)
|
||||
surveys_qs = SurveyInstance.objects.filter(survey_template__hospital=user.hospital)
|
||||
social_qs = SocialMediaComment.objects.all() # Social media is organization-wide, not hospital-specific
|
||||
calls_qs = CallCenterInteraction.objects.filter(hospital=user.hospital)
|
||||
observations_qs = Observation.objects.filter(hospital=user.hospital)
|
||||
elif user.is_department_manager() and user.department:
|
||||
complaints_qs = Complaint.objects.filter(department=user.department)
|
||||
inquiries_qs = Inquiry.objects.filter(department=user.department)
|
||||
actions_qs = PXAction.objects.filter(department=user.department)
|
||||
surveys_qs = SurveyInstance.objects.filter(journey_stage_instance__department=user.department)
|
||||
social_qs = SocialMediaComment.objects.all() # Social media is organization-wide, not department-specific
|
||||
calls_qs = CallCenterInteraction.objects.filter(department=user.department)
|
||||
observations_qs = Observation.objects.filter(assigned_department=user.department)
|
||||
else:
|
||||
complaints_qs = Complaint.objects.none()
|
||||
inquiries_qs = Inquiry.objects.none()
|
||||
actions_qs = PXAction.objects.none()
|
||||
surveys_qs = SurveyInstance.objects.none()
|
||||
social_qs = SocialMediaComment.objects.all() # Show all social media comments
|
||||
calls_qs = CallCenterInteraction.objects.none()
|
||||
observations_qs = Observation.objects.none()
|
||||
|
||||
# Top KPI Stats
|
||||
context['stats'] = [
|
||||
{
|
||||
'label': _("Active Complaints"),
|
||||
'value': complaints_qs.filter(status__in=['open', 'in_progress']).count(),
|
||||
'icon': 'exclamation-triangle',
|
||||
'color': 'danger'
|
||||
},
|
||||
{
|
||||
# ========================================
|
||||
# RED ALERT ITEMS (5-Second Rule)
|
||||
# ========================================
|
||||
red_alerts = []
|
||||
|
||||
# Critical complaints
|
||||
critical_complaints = complaints_qs.filter(
|
||||
severity='critical',
|
||||
status__in=['open', 'in_progress']
|
||||
).count()
|
||||
if critical_complaints > 0:
|
||||
red_alerts.append({
|
||||
'type': 'critical_complaints',
|
||||
'label': _('Critical Complaints'),
|
||||
'value': critical_complaints,
|
||||
'icon': 'alert-octagon',
|
||||
'color': 'red',
|
||||
'url': f"{reverse('complaints:complaint_list')}?severity=critical&status=open,in_progress",
|
||||
'priority': 1
|
||||
})
|
||||
|
||||
# Overdue complaints
|
||||
overdue_complaints = complaints_qs.filter(
|
||||
is_overdue=True,
|
||||
status__in=['open', 'in_progress']
|
||||
).count()
|
||||
if overdue_complaints > 0:
|
||||
red_alerts.append({
|
||||
'type': 'overdue_complaints',
|
||||
'label': _('Overdue Complaints'),
|
||||
'value': complaints_qs.filter(is_overdue=True, status__in=['open', 'in_progress']).count(),
|
||||
'icon': 'clock-history',
|
||||
'color': 'warning'
|
||||
},
|
||||
{
|
||||
'label': _('Open PX Actions'),
|
||||
'value': actions_qs.filter(status__in=['open', 'in_progress']).count(),
|
||||
'icon': 'clipboard-check',
|
||||
'color': 'primary'
|
||||
},
|
||||
{
|
||||
'label': _('Overdue Actions'),
|
||||
'value': actions_qs.filter(is_overdue=True, status__in=['open', 'in_progress']).count(),
|
||||
'icon': 'alarm',
|
||||
'color': 'danger'
|
||||
},
|
||||
{
|
||||
'value': overdue_complaints,
|
||||
'icon': 'clock',
|
||||
'color': 'orange',
|
||||
'url': f"{reverse('complaints:complaint_list')}?is_overdue=true&status=open,in_progress",
|
||||
'priority': 2
|
||||
})
|
||||
|
||||
# Escalated actions
|
||||
escalated_actions = actions_qs.filter(
|
||||
escalation_level__gt=0,
|
||||
status__in=['open', 'in_progress']
|
||||
).count()
|
||||
if escalated_actions > 0:
|
||||
red_alerts.append({
|
||||
'type': 'escalated_actions',
|
||||
'label': _('Escalated Actions'),
|
||||
'value': escalated_actions,
|
||||
'icon': 'arrow-up-circle',
|
||||
'color': 'red',
|
||||
'url': reverse('actions:action_list'),
|
||||
'priority': 3
|
||||
})
|
||||
|
||||
# Negative surveys in last 24h
|
||||
negative_surveys_24h = surveys_qs.filter(
|
||||
is_negative=True,
|
||||
completed_at__gte=last_24h
|
||||
).count()
|
||||
if negative_surveys_24h > 0:
|
||||
red_alerts.append({
|
||||
'type': 'negative_surveys',
|
||||
'label': _('Negative Surveys (24h)'),
|
||||
'value': surveys_qs.filter(is_negative=True, completed_at__gte=last_24h).count(),
|
||||
'icon': 'emoji-frown',
|
||||
'color': 'warning'
|
||||
},
|
||||
{
|
||||
'label': _('Negative Social Mentions'),
|
||||
'value': sum(
|
||||
1 for comment in social_qs.filter(published_at__gte=last_7d)
|
||||
if comment.ai_analysis and
|
||||
comment.ai_analysis.get('sentiment', {}).get('classification', {}).get('en') == 'negative'
|
||||
),
|
||||
'icon': 'chat-dots',
|
||||
'color': 'danger'
|
||||
},
|
||||
{
|
||||
'label': _('Low Call Center Ratings'),
|
||||
'value': calls_qs.filter(is_low_rating=True, call_started_at__gte=last_7d).count(),
|
||||
'icon': 'telephone',
|
||||
'color': 'warning'
|
||||
},
|
||||
{
|
||||
'label': _('Avg Survey Score'),
|
||||
'value': f"{surveys_qs.filter(completed_at__gte=last_30d).aggregate(Avg('total_score'))['total_score__avg'] or 0:.1f}",
|
||||
'icon': 'star',
|
||||
'color': 'success'
|
||||
},
|
||||
]
|
||||
'value': negative_surveys_24h,
|
||||
'icon': 'frown',
|
||||
'color': 'yellow',
|
||||
'url': reverse('surveys:instance_list'),
|
||||
'priority': 4
|
||||
})
|
||||
|
||||
# Sort by priority
|
||||
red_alerts.sort(key=lambda x: x['priority'])
|
||||
context['red_alerts'] = red_alerts
|
||||
context['has_red_alerts'] = len(red_alerts) > 0
|
||||
|
||||
# ========================================
|
||||
# COMPLAINTS MODULE DATA
|
||||
# ========================================
|
||||
complaints_current = complaints_qs.filter(created_at__gte=last_30d).count()
|
||||
complaints_previous = complaints_qs.filter(
|
||||
created_at__gte=last_60d,
|
||||
created_at__lt=last_30d
|
||||
).count()
|
||||
complaints_variance = 0
|
||||
if complaints_previous > 0:
|
||||
complaints_variance = round(((complaints_current - complaints_previous) / complaints_previous) * 100, 1)
|
||||
|
||||
# Resolution time calculation
|
||||
resolved_complaints = complaints_qs.filter(
|
||||
status='closed',
|
||||
closed_at__isnull=False,
|
||||
created_at__gte=last_30d
|
||||
)
|
||||
avg_resolution_hours = 0
|
||||
if resolved_complaints.exists():
|
||||
total_hours = sum(
|
||||
(c.closed_at - c.created_at).total_seconds() / 3600
|
||||
for c in resolved_complaints
|
||||
)
|
||||
avg_resolution_hours = round(total_hours / resolved_complaints.count(), 1)
|
||||
|
||||
# Complaints by severity for donut chart
|
||||
complaints_by_severity = {
|
||||
'critical': complaints_qs.filter(severity='critical', status__in=['open', 'in_progress']).count(),
|
||||
'high': complaints_qs.filter(severity='high', status__in=['open', 'in_progress']).count(),
|
||||
'medium': complaints_qs.filter(severity='medium', status__in=['open', 'in_progress']).count(),
|
||||
'low': complaints_qs.filter(severity='low', status__in=['open', 'in_progress']).count(),
|
||||
}
|
||||
|
||||
# Complaints by department for heatmap
|
||||
complaints_by_department = list(
|
||||
complaints_qs.filter(
|
||||
status__in=['open', 'in_progress'],
|
||||
department__isnull=False
|
||||
).values('department__name').annotate(
|
||||
count=Count('id')
|
||||
).order_by('-count')[:10]
|
||||
)
|
||||
|
||||
context['complaints_module'] = {
|
||||
'total_active': complaints_qs.filter(status__in=['open', 'in_progress']).count(),
|
||||
'current_period': complaints_current,
|
||||
'previous_period': complaints_previous,
|
||||
'variance': complaints_variance,
|
||||
'variance_direction': 'up' if complaints_variance > 0 else 'down' if complaints_variance < 0 else 'neutral',
|
||||
'avg_resolution_hours': avg_resolution_hours,
|
||||
'overdue': complaints_qs.filter(is_overdue=True, status__in=['open', 'in_progress']).count(),
|
||||
'by_severity': complaints_by_severity,
|
||||
'by_department': complaints_by_department,
|
||||
'critical_new': complaints_qs.filter(severity='critical', created_at__gte=last_24h).count(),
|
||||
}
|
||||
|
||||
# ========================================
|
||||
# SURVEY INSIGHTS MODULE DATA
|
||||
# ========================================
|
||||
surveys_completed_30d = surveys_qs.filter(completed_at__gte=last_30d)
|
||||
total_surveys_30d = surveys_completed_30d.count()
|
||||
|
||||
# Calculate average satisfaction
|
||||
avg_satisfaction = surveys_completed_30d.filter(
|
||||
total_score__isnull=False
|
||||
).aggregate(Avg('total_score'))['total_score__avg'] or 0
|
||||
|
||||
# NPS-style calculation (promoters - detractors)
|
||||
positive_count = surveys_completed_30d.filter(is_negative=False).count()
|
||||
negative_count = surveys_completed_30d.filter(is_negative=True).count()
|
||||
nps_score = 0
|
||||
if total_surveys_30d > 0:
|
||||
nps_score = round(((positive_count - negative_count) / total_surveys_30d) * 100)
|
||||
|
||||
# Response rate (completed vs sent in last 30 days)
|
||||
surveys_sent_30d = surveys_qs.filter(sent_at__gte=last_30d).count()
|
||||
response_rate = 0
|
||||
if surveys_sent_30d > 0:
|
||||
response_rate = round((total_surveys_30d / surveys_sent_30d) * 100, 1)
|
||||
|
||||
context['survey_module'] = {
|
||||
'avg_satisfaction': round(avg_satisfaction, 1),
|
||||
'nps_score': nps_score,
|
||||
'response_rate': response_rate,
|
||||
'total_completed': total_surveys_30d,
|
||||
'positive_count': positive_count,
|
||||
'negative_count': negative_count,
|
||||
'neutral_count': total_surveys_30d - positive_count - negative_count,
|
||||
'negative_24h': surveys_qs.filter(is_negative=True, completed_at__gte=last_24h).count(),
|
||||
}
|
||||
|
||||
# ========================================
|
||||
# PX ACTIONS MODULE DATA
|
||||
# ========================================
|
||||
actions_open = actions_qs.filter(status='open').count()
|
||||
actions_in_progress = actions_qs.filter(status='in_progress').count()
|
||||
actions_pending_approval = actions_qs.filter(status='pending_approval').count()
|
||||
actions_closed_30d = actions_qs.filter(status='closed', closed_at__gte=last_30d).count()
|
||||
|
||||
# Time to close calculation
|
||||
closed_actions = actions_qs.filter(
|
||||
status='closed',
|
||||
closed_at__isnull=False,
|
||||
created_at__gte=last_30d
|
||||
)
|
||||
avg_time_to_close_hours = 0
|
||||
if closed_actions.exists():
|
||||
total_hours = sum(
|
||||
(a.closed_at - a.created_at).total_seconds() / 3600
|
||||
for a in closed_actions
|
||||
)
|
||||
avg_time_to_close_hours = round(total_hours / closed_actions.count(), 1)
|
||||
|
||||
# Actions by source for breakdown
|
||||
actions_by_source = list(
|
||||
actions_qs.filter(
|
||||
status__in=['open', 'in_progress']
|
||||
).values('source_type').annotate(
|
||||
count=Count('id')
|
||||
).order_by('-count')
|
||||
)
|
||||
|
||||
context['actions_module'] = {
|
||||
'open': actions_open,
|
||||
'in_progress': actions_in_progress,
|
||||
'pending_approval': actions_pending_approval,
|
||||
'overdue': actions_qs.filter(is_overdue=True, status__in=['open', 'in_progress']).count(),
|
||||
'escalated': actions_qs.filter(escalation_level__gt=0, status__in=['open', 'in_progress']).count(),
|
||||
'closed_30d': actions_closed_30d,
|
||||
'avg_time_to_close_hours': avg_time_to_close_hours,
|
||||
'by_source': actions_by_source,
|
||||
'new_today': actions_qs.filter(created_at__gte=last_24h).count(),
|
||||
}
|
||||
|
||||
# ========================================
|
||||
# INQUIRIES MODULE DATA
|
||||
# ========================================
|
||||
inquiries_open = inquiries_qs.filter(status='open').count()
|
||||
inquiries_in_progress = inquiries_qs.filter(status='in_progress').count()
|
||||
inquiries_resolved_30d = inquiries_qs.filter(status='resolved', updated_at__gte=last_30d).count()
|
||||
|
||||
context['inquiries_module'] = {
|
||||
'open': inquiries_open,
|
||||
'in_progress': inquiries_in_progress,
|
||||
'total_active': inquiries_open + inquiries_in_progress,
|
||||
'resolved_30d': inquiries_resolved_30d,
|
||||
'new_24h': inquiries_qs.filter(created_at__gte=last_24h).count(),
|
||||
}
|
||||
|
||||
# ========================================
|
||||
# OBSERVATIONS MODULE DATA
|
||||
# ========================================
|
||||
observations_new = observations_qs.filter(status='new').count()
|
||||
observations_in_progress = observations_qs.filter(status='in_progress').count()
|
||||
observations_critical = observations_qs.filter(
|
||||
severity='critical',
|
||||
status__in=['new', 'triaged', 'assigned', 'in_progress']
|
||||
).count()
|
||||
|
||||
# Observations by category
|
||||
observations_by_category = list(
|
||||
observations_qs.filter(
|
||||
status__in=['new', 'triaged', 'assigned', 'in_progress']
|
||||
).values('category__name_en').annotate(
|
||||
count=Count('id')
|
||||
).order_by('-count')[:5]
|
||||
)
|
||||
|
||||
context['observations_module'] = {
|
||||
'new': observations_new,
|
||||
'in_progress': observations_in_progress,
|
||||
'critical': observations_critical,
|
||||
'total_active': observations_new + observations_in_progress,
|
||||
'resolved_30d': observations_qs.filter(status='resolved', resolved_at__gte=last_30d).count(),
|
||||
'by_category': observations_by_category,
|
||||
}
|
||||
|
||||
# ========================================
|
||||
# COMMUNICATION/CALL CENTER MODULE DATA
|
||||
# ========================================
|
||||
calls_7d = calls_qs.filter(call_started_at__gte=last_7d)
|
||||
total_calls = calls_7d.count()
|
||||
low_rating_calls = calls_7d.filter(is_low_rating=True).count()
|
||||
|
||||
context['calls_module'] = {
|
||||
'total_7d': total_calls,
|
||||
'low_ratings': low_rating_calls,
|
||||
'satisfaction_rate': round(((total_calls - low_rating_calls) / total_calls * 100), 1) if total_calls > 0 else 0,
|
||||
}
|
||||
|
||||
# ========================================
|
||||
# LEGACY STATS (for backward compatibility)
|
||||
# ========================================
|
||||
context['stats'] = {
|
||||
'total_complaints': context['complaints_module']['total_active'],
|
||||
'avg_resolution_time': f"{avg_resolution_hours}h",
|
||||
'satisfaction_score': round(avg_satisfaction, 0),
|
||||
'active_actions': context['actions_module']['open'] + context['actions_module']['in_progress'],
|
||||
'new_today': context['actions_module']['new_today'],
|
||||
}
|
||||
|
||||
# ========================================
|
||||
# LATEST ITEMS FOR LIVE FEED
|
||||
# ========================================
|
||||
# Latest high severity complaints
|
||||
context['latest_complaints'] = complaints_qs.filter(
|
||||
severity__in=['high', 'critical']
|
||||
@ -150,12 +393,24 @@ class CommandCenterView(LoginRequiredMixin, TemplateView):
|
||||
escalation_level__gt=0
|
||||
).select_related('hospital', 'assigned_to').order_by('-escalated_at')[:5]
|
||||
|
||||
# Latest inquiries
|
||||
context['latest_inquiries'] = inquiries_qs.filter(
|
||||
status__in=['open', 'in_progress']
|
||||
).select_related('patient', 'hospital', 'department').order_by('-created_at')[:5]
|
||||
|
||||
# Latest observations
|
||||
context['latest_observations'] = observations_qs.filter(
|
||||
status__in=['new', 'triaged', 'assigned']
|
||||
).select_related('hospital', 'category').order_by('-created_at')[:5]
|
||||
|
||||
# Latest integration events
|
||||
context['latest_events'] = InboundEvent.objects.filter(
|
||||
status='processed'
|
||||
).select_related().order_by('-processed_at')[:10]
|
||||
|
||||
# Staff ratings data
|
||||
# ========================================
|
||||
# PHYSICIAN LEADERBOARD
|
||||
# ========================================
|
||||
current_month_ratings = PhysicianMonthlyRating.objects.filter(
|
||||
year=now.year,
|
||||
month=now.month
|
||||
@ -178,16 +433,28 @@ class CommandCenterView(LoginRequiredMixin, TemplateView):
|
||||
)
|
||||
context['physician_stats'] = physician_stats
|
||||
|
||||
# Chart data (simplified for now)
|
||||
import json
|
||||
# ========================================
|
||||
# CHART DATA
|
||||
# ========================================
|
||||
context['chart_data'] = {
|
||||
'complaints_trend': json.dumps(self.get_complaints_trend(complaints_qs, last_30d)),
|
||||
'survey_satisfaction': self.get_survey_satisfaction(surveys_qs, last_30d),
|
||||
'complaints_by_severity': json.dumps(complaints_by_severity),
|
||||
'survey_satisfaction': avg_satisfaction,
|
||||
'nps_trend': json.dumps(self.get_nps_trend(surveys_qs, last_30d)),
|
||||
'actions_funnel': json.dumps({
|
||||
'open': actions_open,
|
||||
'in_progress': actions_in_progress,
|
||||
'pending_approval': actions_pending_approval,
|
||||
'closed': actions_closed_30d,
|
||||
}),
|
||||
}
|
||||
|
||||
# Add hospital context
|
||||
context['current_hospital'] = self.request.tenant_hospital
|
||||
context['is_px_admin'] = user.is_px_admin()
|
||||
|
||||
# Last updated timestamp
|
||||
context['last_updated'] = now.strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
return context
|
||||
|
||||
@ -206,6 +473,25 @@ class CommandCenterView(LoginRequiredMixin, TemplateView):
|
||||
})
|
||||
return data
|
||||
|
||||
def get_nps_trend(self, queryset, start_date):
|
||||
"""Get NPS trend data for chart"""
|
||||
data = []
|
||||
for i in range(30):
|
||||
date = start_date + timedelta(days=i)
|
||||
day_surveys = queryset.filter(completed_at__date=date.date())
|
||||
total = day_surveys.count()
|
||||
if total > 0:
|
||||
positive = day_surveys.filter(is_negative=False).count()
|
||||
negative = day_surveys.filter(is_negative=True).count()
|
||||
nps = round(((positive - negative) / total) * 100)
|
||||
else:
|
||||
nps = 0
|
||||
data.append({
|
||||
'date': date.strftime('%Y-%m-%d'),
|
||||
'nps': nps
|
||||
})
|
||||
return data
|
||||
|
||||
def get_survey_satisfaction(self, queryset, start_date):
|
||||
"""Get survey satisfaction averages"""
|
||||
return queryset.filter(
|
||||
@ -234,8 +520,15 @@ def my_dashboard(request):
|
||||
- Bulk actions support
|
||||
- Charts showing trends
|
||||
"""
|
||||
# Redirect Source Users to their dashboard
|
||||
if request.user.is_source_user():
|
||||
return redirect('px_sources:source_user_dashboard')
|
||||
|
||||
user = request.user
|
||||
|
||||
# Get selected hospital for PX Admins (from middleware)
|
||||
selected_hospital = getattr(request, 'tenant_hospital', None)
|
||||
|
||||
# Get date range filter
|
||||
date_range_days = int(request.GET.get('date_range', 30))
|
||||
if date_range_days == -1: # All time
|
||||
@ -261,6 +554,9 @@ def my_dashboard(request):
|
||||
# 1. Complaints
|
||||
from apps.complaints.models import Complaint
|
||||
complaints_qs = Complaint.objects.filter(assigned_to=user)
|
||||
# Filter by selected hospital for PX Admins
|
||||
if selected_hospital:
|
||||
complaints_qs = complaints_qs.filter(hospital=selected_hospital)
|
||||
if start_date:
|
||||
complaints_qs = complaints_qs.filter(created_at__gte=start_date)
|
||||
if search_query:
|
||||
@ -279,6 +575,9 @@ def my_dashboard(request):
|
||||
# 2. Inquiries
|
||||
from apps.complaints.models import Inquiry
|
||||
inquiries_qs = Inquiry.objects.filter(assigned_to=user)
|
||||
# Filter by selected hospital for PX Admins
|
||||
if selected_hospital:
|
||||
inquiries_qs = inquiries_qs.filter(hospital=selected_hospital)
|
||||
if start_date:
|
||||
inquiries_qs = inquiries_qs.filter(created_at__gte=start_date)
|
||||
if search_query:
|
||||
@ -295,6 +594,9 @@ def my_dashboard(request):
|
||||
# 3. Observations
|
||||
from apps.observations.models import Observation
|
||||
observations_qs = Observation.objects.filter(assigned_to=user)
|
||||
# Filter by selected hospital for PX Admins
|
||||
if selected_hospital:
|
||||
observations_qs = observations_qs.filter(hospital=selected_hospital)
|
||||
if start_date:
|
||||
observations_qs = observations_qs.filter(created_at__gte=start_date)
|
||||
if search_query:
|
||||
@ -313,6 +615,9 @@ def my_dashboard(request):
|
||||
# 4. PX Actions
|
||||
from apps.px_action_center.models import PXAction
|
||||
actions_qs = PXAction.objects.filter(assigned_to=user)
|
||||
# Filter by selected hospital for PX Admins
|
||||
if selected_hospital:
|
||||
actions_qs = actions_qs.filter(hospital=selected_hospital)
|
||||
if start_date:
|
||||
actions_qs = actions_qs.filter(created_at__gte=start_date)
|
||||
if search_query:
|
||||
@ -331,6 +636,9 @@ def my_dashboard(request):
|
||||
# 5. QI Project Tasks
|
||||
from apps.projects.models import QIProjectTask
|
||||
tasks_qs = QIProjectTask.objects.filter(assigned_to=user)
|
||||
# Filter by selected hospital for PX Admins (via project)
|
||||
if selected_hospital:
|
||||
tasks_qs = tasks_qs.filter(project__hospital=selected_hospital)
|
||||
if start_date:
|
||||
tasks_qs = tasks_qs.filter(created_at__gte=start_date)
|
||||
if search_query:
|
||||
@ -345,6 +653,9 @@ def my_dashboard(request):
|
||||
# 6. Feedback
|
||||
from apps.feedback.models import Feedback
|
||||
feedback_qs = Feedback.objects.filter(assigned_to=user)
|
||||
# Filter by selected hospital for PX Admins
|
||||
if selected_hospital:
|
||||
feedback_qs = feedback_qs.filter(hospital=selected_hospital)
|
||||
if start_date:
|
||||
feedback_qs = feedback_qs.filter(created_at__gte=start_date)
|
||||
if search_query:
|
||||
@ -496,7 +807,7 @@ def my_dashboard(request):
|
||||
paginated_data[tab_name] = paginator.get_page(page_number)
|
||||
|
||||
# Get chart data
|
||||
chart_data = get_dashboard_chart_data(user, start_date)
|
||||
chart_data = get_dashboard_chart_data(user, start_date, selected_hospital)
|
||||
|
||||
context = {
|
||||
'stats': stats,
|
||||
@ -508,12 +819,13 @@ def my_dashboard(request):
|
||||
'status_filter': status_filter,
|
||||
'priority_filter': priority_filter,
|
||||
'chart_data': chart_data,
|
||||
'selected_hospital': selected_hospital, # For hospital filter display
|
||||
}
|
||||
|
||||
return render(request, 'dashboard/my_dashboard.html', context)
|
||||
|
||||
|
||||
def get_dashboard_chart_data(user, start_date=None):
|
||||
def get_dashboard_chart_data(user, start_date=None, selected_hospital=None):
|
||||
"""
|
||||
Get chart data for dashboard trends.
|
||||
|
||||
@ -542,36 +854,60 @@ def get_dashboard_chart_data(user, start_date=None):
|
||||
|
||||
completed_count = 0
|
||||
# Check each model for completions on this date
|
||||
completed_count += Complaint.objects.filter(
|
||||
# Apply hospital filter for PX Admins
|
||||
complaint_qs = Complaint.objects.filter(
|
||||
assigned_to=user,
|
||||
status='closed',
|
||||
closed_at__date=date.date()
|
||||
).count()
|
||||
completed_count += Inquiry.objects.filter(
|
||||
)
|
||||
if selected_hospital:
|
||||
complaint_qs = complaint_qs.filter(hospital=selected_hospital)
|
||||
completed_count += complaint_qs.count()
|
||||
|
||||
inquiry_qs = Inquiry.objects.filter(
|
||||
assigned_to=user,
|
||||
status='closed',
|
||||
updated_at__date=date.date()
|
||||
).count()
|
||||
completed_count += Observation.objects.filter(
|
||||
)
|
||||
if selected_hospital:
|
||||
inquiry_qs = inquiry_qs.filter(hospital=selected_hospital)
|
||||
completed_count += inquiry_qs.count()
|
||||
|
||||
observation_qs = Observation.objects.filter(
|
||||
assigned_to=user,
|
||||
status='closed',
|
||||
updated_at__date=date.date()
|
||||
).count()
|
||||
completed_count += PXAction.objects.filter(
|
||||
)
|
||||
if selected_hospital:
|
||||
observation_qs = observation_qs.filter(hospital=selected_hospital)
|
||||
completed_count += observation_qs.count()
|
||||
|
||||
action_qs = PXAction.objects.filter(
|
||||
assigned_to=user,
|
||||
status='closed',
|
||||
closed_at__date=date.date()
|
||||
).count()
|
||||
completed_count += QIProjectTask.objects.filter(
|
||||
)
|
||||
if selected_hospital:
|
||||
action_qs = action_qs.filter(hospital=selected_hospital)
|
||||
completed_count += action_qs.count()
|
||||
|
||||
task_qs = QIProjectTask.objects.filter(
|
||||
assigned_to=user,
|
||||
status='closed',
|
||||
completed_date=date.date()
|
||||
).count()
|
||||
completed_count += Feedback.objects.filter(
|
||||
)
|
||||
if selected_hospital:
|
||||
task_qs = task_qs.filter(project__hospital=selected_hospital)
|
||||
completed_count += task_qs.count()
|
||||
|
||||
feedback_qs = Feedback.objects.filter(
|
||||
assigned_to=user,
|
||||
status='closed',
|
||||
closed_at__date=date.date()
|
||||
).count()
|
||||
)
|
||||
if selected_hospital:
|
||||
feedback_qs = feedback_qs.filter(hospital=selected_hospital)
|
||||
completed_count += feedback_qs.count()
|
||||
|
||||
completion_data.append(completed_count)
|
||||
|
||||
@ -667,13 +1003,20 @@ def admin_evaluation(request):
|
||||
- Multi-staff comparison
|
||||
- Date range filtering
|
||||
- Hospital/department filtering
|
||||
|
||||
Access: PX Admin and Hospital Admin only
|
||||
"""
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from apps.analytics.services.analytics_service import UnifiedAnalyticsService
|
||||
from apps.accounts.models import User
|
||||
from apps.organizations.models import Hospital, Department
|
||||
|
||||
user = request.user
|
||||
|
||||
# Only PX Admins and Hospital Admins can access
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
raise PermissionDenied("Only PX Admins and Hospital Admins can access the Admin Evaluation dashboard.")
|
||||
|
||||
# Get date range filter
|
||||
date_range = request.GET.get('date_range', '30d')
|
||||
custom_start = request.GET.get('custom_start')
|
||||
@ -759,6 +1102,8 @@ def admin_evaluation(request):
|
||||
def admin_evaluation_chart_data(request):
|
||||
"""
|
||||
API endpoint to get chart data for admin evaluation dashboard.
|
||||
|
||||
Access: PX Admin and Hospital Admin only
|
||||
"""
|
||||
from apps.analytics.services.analytics_service import UnifiedAnalyticsService
|
||||
|
||||
@ -766,6 +1111,11 @@ def admin_evaluation_chart_data(request):
|
||||
return JsonResponse({'success': False, 'error': 'GET required'}, status=405)
|
||||
|
||||
user = request.user
|
||||
|
||||
# Only PX Admins and Hospital Admins can access
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
return JsonResponse({'success': False, 'error': 'Permission denied'}, status=403)
|
||||
|
||||
chart_type = request.GET.get('chart_type')
|
||||
date_range = request.GET.get('date_range', '30d')
|
||||
hospital_id = request.GET.get('hospital_id')
|
||||
@ -817,8 +1167,15 @@ def staff_performance_detail(request, staff_id):
|
||||
- Daily workload trends
|
||||
- Recent complaints and inquiries
|
||||
- Performance metrics
|
||||
|
||||
Access: PX Admin and Hospital Admin only
|
||||
"""
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from apps.analytics.services.analytics_service import UnifiedAnalyticsService
|
||||
|
||||
# Only PX Admins and Hospital Admins can access
|
||||
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
|
||||
raise PermissionDenied("Only PX Admins and Hospital Admins can access staff performance details.")
|
||||
from apps.accounts.models import User
|
||||
|
||||
user = request.user
|
||||
@ -870,10 +1227,16 @@ def staff_performance_detail(request, staff_id):
|
||||
def staff_performance_trends(request, staff_id):
|
||||
"""
|
||||
API endpoint to get staff performance trends as JSON.
|
||||
|
||||
Access: PX Admin and Hospital Admin only
|
||||
"""
|
||||
from apps.analytics.services.analytics_service import UnifiedAnalyticsService
|
||||
from apps.accounts.models import User
|
||||
|
||||
# Only PX Admins and Hospital Admins can access
|
||||
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
|
||||
return JsonResponse({'success': False, 'error': 'Permission denied'}, status=403)
|
||||
|
||||
user = request.user
|
||||
months = int(request.GET.get('months', 6))
|
||||
|
||||
@ -892,9 +1255,16 @@ def staff_performance_trends(request, staff_id):
|
||||
def department_benchmarks(request):
|
||||
"""
|
||||
Department benchmarking view comparing all staff.
|
||||
|
||||
Access: PX Admin and Hospital Admin only
|
||||
"""
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from apps.analytics.services.analytics_service import UnifiedAnalyticsService
|
||||
|
||||
# Only PX Admins and Hospital Admins can access
|
||||
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
|
||||
raise PermissionDenied("Only PX Admins and Hospital Admins can access department benchmarks.")
|
||||
|
||||
user = request.user
|
||||
|
||||
# Get filters
|
||||
@ -928,12 +1298,19 @@ def department_benchmarks(request):
|
||||
def export_staff_performance(request):
|
||||
"""
|
||||
Export staff performance report in various formats.
|
||||
|
||||
Access: PX Admin and Hospital Admin only
|
||||
"""
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from apps.analytics.services.analytics_service import UnifiedAnalyticsService
|
||||
import csv
|
||||
import json
|
||||
from django.http import HttpResponse
|
||||
|
||||
# Only PX Admins and Hospital Admins can access
|
||||
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
|
||||
raise PermissionDenied("Only PX Admins and Hospital Admins can export staff performance.")
|
||||
|
||||
user = request.user
|
||||
|
||||
if request.method != 'POST':
|
||||
@ -974,13 +1351,157 @@ def export_staff_performance(request):
|
||||
return JsonResponse({'error': str(e)}, status=500)
|
||||
|
||||
|
||||
@login_required
|
||||
def command_center_api(request):
|
||||
"""
|
||||
API endpoint for Command Center live data updates.
|
||||
|
||||
Returns JSON with all module data for AJAX refresh without page reload.
|
||||
Enables true real-time updates every 30-60 seconds.
|
||||
"""
|
||||
from apps.complaints.models import Complaint, Inquiry
|
||||
from apps.px_action_center.models import PXAction
|
||||
from apps.surveys.models import SurveyInstance
|
||||
from apps.physicians.models import PhysicianMonthlyRating
|
||||
from apps.observations.models import Observation
|
||||
|
||||
user = request.user
|
||||
now = timezone.now()
|
||||
last_24h = now - timedelta(hours=24)
|
||||
last_30d = now - timedelta(days=30)
|
||||
last_60d = now - timedelta(days=60)
|
||||
|
||||
# Build querysets based on user role
|
||||
if user.is_px_admin():
|
||||
hospital = request.tenant_hospital
|
||||
complaints_qs = Complaint.objects.filter(hospital=hospital) if hospital else Complaint.objects.none()
|
||||
inquiries_qs = Inquiry.objects.filter(hospital=hospital) if hospital else Inquiry.objects.none()
|
||||
actions_qs = PXAction.objects.filter(hospital=hospital) if hospital else PXAction.objects.none()
|
||||
surveys_qs = SurveyInstance.objects.all()
|
||||
observations_qs = Observation.objects.filter(hospital=hospital) if hospital else Observation.objects.none()
|
||||
elif user.is_hospital_admin() and user.hospital:
|
||||
complaints_qs = Complaint.objects.filter(hospital=user.hospital)
|
||||
inquiries_qs = Inquiry.objects.filter(hospital=user.hospital)
|
||||
actions_qs = PXAction.objects.filter(hospital=user.hospital)
|
||||
surveys_qs = SurveyInstance.objects.filter(survey_template__hospital=user.hospital)
|
||||
observations_qs = Observation.objects.filter(hospital=user.hospital)
|
||||
elif user.is_department_manager() and user.department:
|
||||
complaints_qs = Complaint.objects.filter(department=user.department)
|
||||
inquiries_qs = Inquiry.objects.filter(department=user.department)
|
||||
actions_qs = PXAction.objects.filter(department=user.department)
|
||||
surveys_qs = SurveyInstance.objects.filter(journey_stage_instance__department=user.department)
|
||||
observations_qs = Observation.objects.filter(assigned_department=user.department)
|
||||
else:
|
||||
complaints_qs = Complaint.objects.none()
|
||||
inquiries_qs = Inquiry.objects.none()
|
||||
actions_qs = PXAction.objects.none()
|
||||
surveys_qs = SurveyInstance.objects.none()
|
||||
observations_qs = Observation.objects.none()
|
||||
|
||||
# Calculate all module data
|
||||
# Complaints
|
||||
complaints_current = complaints_qs.filter(created_at__gte=last_30d).count()
|
||||
complaints_previous = complaints_qs.filter(created_at__gte=last_60d, created_at__lt=last_30d).count()
|
||||
complaints_variance = round(((complaints_current - complaints_previous) / complaints_previous) * 100, 1) if complaints_previous > 0 else 0
|
||||
|
||||
# Surveys
|
||||
surveys_completed_30d = surveys_qs.filter(completed_at__gte=last_30d)
|
||||
total_surveys_30d = surveys_completed_30d.count()
|
||||
positive_count = surveys_completed_30d.filter(is_negative=False).count()
|
||||
negative_count = surveys_completed_30d.filter(is_negative=True).count()
|
||||
nps_score = round(((positive_count - negative_count) / total_surveys_30d) * 100) if total_surveys_30d > 0 else 0
|
||||
avg_satisfaction = surveys_completed_30d.filter(total_score__isnull=False).aggregate(Avg('total_score'))['total_score__avg'] or 0
|
||||
surveys_sent_30d = surveys_qs.filter(sent_at__gte=last_30d).count()
|
||||
response_rate = round((total_surveys_30d / surveys_sent_30d) * 100, 1) if surveys_sent_30d > 0 else 0
|
||||
|
||||
# Actions
|
||||
actions_open = actions_qs.filter(status='open').count()
|
||||
actions_in_progress = actions_qs.filter(status='in_progress').count()
|
||||
actions_pending_approval = actions_qs.filter(status='pending_approval').count()
|
||||
actions_closed_30d = actions_qs.filter(status='closed', closed_at__gte=last_30d).count()
|
||||
|
||||
# Red alerts
|
||||
red_alerts = []
|
||||
critical_complaints = complaints_qs.filter(severity='critical', status__in=['open', 'in_progress']).count()
|
||||
if critical_complaints > 0:
|
||||
red_alerts.append({'type': 'critical_complaints', 'value': critical_complaints})
|
||||
overdue_complaints = complaints_qs.filter(is_overdue=True, status__in=['open', 'in_progress']).count()
|
||||
if overdue_complaints > 0:
|
||||
red_alerts.append({'type': 'overdue_complaints', 'value': overdue_complaints})
|
||||
escalated_actions = actions_qs.filter(escalation_level__gt=0, status__in=['open', 'in_progress']).count()
|
||||
if escalated_actions > 0:
|
||||
red_alerts.append({'type': 'escalated_actions', 'value': escalated_actions})
|
||||
negative_surveys_24h = surveys_qs.filter(is_negative=True, completed_at__gte=last_24h).count()
|
||||
if negative_surveys_24h > 0:
|
||||
red_alerts.append({'type': 'negative_surveys', 'value': negative_surveys_24h})
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'timestamp': now.isoformat(),
|
||||
'last_updated': now.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'red_alerts': {
|
||||
'has_alerts': len(red_alerts) > 0,
|
||||
'count': len(red_alerts),
|
||||
'items': red_alerts
|
||||
},
|
||||
'modules': {
|
||||
'complaints': {
|
||||
'total_active': complaints_qs.filter(status__in=['open', 'in_progress']).count(),
|
||||
'variance': complaints_variance,
|
||||
'variance_direction': 'up' if complaints_variance > 0 else 'down' if complaints_variance < 0 else 'neutral',
|
||||
'overdue': complaints_qs.filter(is_overdue=True, status__in=['open', 'in_progress']).count(),
|
||||
'critical': complaints_qs.filter(severity='critical', status__in=['open', 'in_progress']).count(),
|
||||
'by_severity': {
|
||||
'critical': complaints_qs.filter(severity='critical', status__in=['open', 'in_progress']).count(),
|
||||
'high': complaints_qs.filter(severity='high', status__in=['open', 'in_progress']).count(),
|
||||
'medium': complaints_qs.filter(severity='medium', status__in=['open', 'in_progress']).count(),
|
||||
'low': complaints_qs.filter(severity='low', status__in=['open', 'in_progress']).count(),
|
||||
}
|
||||
},
|
||||
'surveys': {
|
||||
'nps_score': nps_score,
|
||||
'avg_satisfaction': round(avg_satisfaction, 1),
|
||||
'response_rate': response_rate,
|
||||
'total_completed': total_surveys_30d,
|
||||
'negative_24h': surveys_qs.filter(is_negative=True, completed_at__gte=last_24h).count()
|
||||
},
|
||||
'actions': {
|
||||
'open': actions_open,
|
||||
'in_progress': actions_in_progress,
|
||||
'pending_approval': actions_pending_approval,
|
||||
'closed_30d': actions_closed_30d,
|
||||
'overdue': actions_qs.filter(is_overdue=True, status__in=['open', 'in_progress']).count(),
|
||||
'escalated': actions_qs.filter(escalation_level__gt=0, status__in=['open', 'in_progress']).count()
|
||||
},
|
||||
'inquiries': {
|
||||
'open': inquiries_qs.filter(status='open').count(),
|
||||
'in_progress': inquiries_qs.filter(status='in_progress').count(),
|
||||
'total_active': inquiries_qs.filter(status__in=['open', 'in_progress']).count(),
|
||||
'new_24h': inquiries_qs.filter(created_at__gte=last_24h).count()
|
||||
},
|
||||
'observations': {
|
||||
'new': observations_qs.filter(status='new').count(),
|
||||
'in_progress': observations_qs.filter(status='in_progress').count(),
|
||||
'total_active': observations_qs.filter(status__in=['new', 'in_progress']).count(),
|
||||
'critical': observations_qs.filter(severity='critical', status__in=['new', 'triaged', 'assigned', 'in_progress']).count()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def performance_analytics_api(request):
|
||||
"""
|
||||
API endpoint for various performance analytics.
|
||||
|
||||
Access: PX Admin and Hospital Admin only
|
||||
"""
|
||||
from apps.analytics.services.analytics_service import UnifiedAnalyticsService
|
||||
|
||||
# Only PX Admins and Hospital Admins can access
|
||||
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
|
||||
return JsonResponse({'success': False, 'error': 'Permission denied'}, status=403)
|
||||
|
||||
user = request.user
|
||||
chart_type = request.GET.get('chart_type')
|
||||
|
||||
|
||||
@ -48,8 +48,13 @@ def feedback_list(request):
|
||||
|
||||
# Apply RBAC filters
|
||||
user = request.user
|
||||
# Get selected hospital for PX Admins (from middleware)
|
||||
selected_hospital = getattr(request, 'tenant_hospital', None)
|
||||
|
||||
if user.is_px_admin():
|
||||
pass # See all
|
||||
# PX Admins see all, but filter by selected hospital if set
|
||||
if selected_hospital:
|
||||
queryset = queryset.filter(hospital=selected_hospital)
|
||||
elif user.is_hospital_admin() and user.hospital:
|
||||
queryset = queryset.filter(hospital=user.hospital)
|
||||
elif user.is_department_manager() and user.department:
|
||||
|
||||
@ -1 +0,0 @@
|
||||
# Integrations management commands
|
||||
@ -1 +0,0 @@
|
||||
# Integrations management commands
|
||||
182
apps/integrations/management/commands/fetch_his_surveys.py
Normal file
182
apps/integrations/management/commands/fetch_his_surveys.py
Normal file
@ -0,0 +1,182 @@
|
||||
"""
|
||||
Management command to manually fetch surveys from HIS system.
|
||||
|
||||
Usage:
|
||||
python manage.py fetch_his_surveys [--minutes N] [--limit N] [--test]
|
||||
|
||||
Options:
|
||||
--minutes N Fetch patients discharged in the last N minutes (default: 10)
|
||||
--limit N Maximum number of patients to fetch (default: 100)
|
||||
--test Test connection only, don't fetch surveys
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.integrations.services.his_client import HISClient, HISClientFactory
|
||||
from apps.integrations.services.his_adapter import HISAdapter
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Fetch surveys from HIS system'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--minutes',
|
||||
type=int,
|
||||
default=10,
|
||||
help='Fetch patients discharged in the last N minutes (default: 10)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--limit',
|
||||
type=int,
|
||||
default=100,
|
||||
help='Maximum number of patients to fetch (default: 100)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--test',
|
||||
action='store_true',
|
||||
help='Test connection only, don\'t fetch surveys'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--config',
|
||||
type=str,
|
||||
help='Configuration name to use (optional)'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
minutes = options['minutes']
|
||||
limit = options['limit']
|
||||
test_only = options['test']
|
||||
config_name = options.get('config')
|
||||
|
||||
self.stdout.write(self.style.MIGRATE_HEADING('=' * 70))
|
||||
self.stdout.write(self.style.MIGRATE_HEADING('HIS Survey Fetch'))
|
||||
self.stdout.write(self.style.MIGRATE_HEADING('=' * 70))
|
||||
|
||||
# Get clients
|
||||
if config_name:
|
||||
from apps.integrations.models import IntegrationConfig, SourceSystem
|
||||
config = IntegrationConfig.objects.filter(
|
||||
name=config_name,
|
||||
source_system=SourceSystem.HIS
|
||||
).first()
|
||||
if not config:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f'Configuration "{config_name}" not found')
|
||||
)
|
||||
return
|
||||
clients = [HISClient(config)]
|
||||
else:
|
||||
clients = HISClientFactory.get_all_active_clients()
|
||||
|
||||
if not clients:
|
||||
self.stdout.write(
|
||||
self.style.ERROR('No active HIS configurations found')
|
||||
)
|
||||
return
|
||||
|
||||
self.stdout.write(f'Found {len(clients)} HIS configuration(s)')
|
||||
|
||||
total_patients = 0
|
||||
total_surveys_created = 0
|
||||
total_surveys_sent = 0
|
||||
|
||||
for client in clients:
|
||||
config_display = client.config.name if client.config else 'Default'
|
||||
self.stdout.write(self.style.MIGRATE_HEADING(f'\n📡 Configuration: {config_display}'))
|
||||
|
||||
# Test connection
|
||||
self.stdout.write('Testing connection...', ending=' ')
|
||||
test_result = client.test_connection()
|
||||
|
||||
if test_result['success']:
|
||||
self.stdout.write(self.style.SUCCESS('✓ Connected'))
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f'✗ Failed: {test_result["message"]}')
|
||||
)
|
||||
continue
|
||||
|
||||
if test_only:
|
||||
continue
|
||||
|
||||
# Calculate fetch window
|
||||
from datetime import timedelta
|
||||
fetch_since = timezone.now() - timedelta(minutes=minutes)
|
||||
|
||||
self.stdout.write(
|
||||
f'Fetching discharged patients since {fetch_since.strftime("%Y-%m-%d %H:%M:%S")}...'
|
||||
)
|
||||
|
||||
# Fetch patients
|
||||
patients = client.fetch_discharged_patients(since=fetch_since, limit=limit)
|
||||
|
||||
if not patients:
|
||||
self.stdout.write(self.style.WARNING('No patients found'))
|
||||
continue
|
||||
|
||||
self.stdout.write(f'Found {len(patients)} patient(s)')
|
||||
|
||||
# Process each patient
|
||||
for i, patient_data in enumerate(patients, 1):
|
||||
self.stdout.write(f'\n Processing patient {i}/{len(patients)}...')
|
||||
|
||||
try:
|
||||
# Ensure proper format
|
||||
if isinstance(patient_data, dict):
|
||||
if 'FetchPatientDataTimeStampList' not in patient_data:
|
||||
patient_data = {
|
||||
'FetchPatientDataTimeStampList': [patient_data],
|
||||
'FetchPatientDataTimeStampVisitDataList': [],
|
||||
'Code': 200,
|
||||
'Status': 'Success'
|
||||
}
|
||||
|
||||
# Process
|
||||
result = HISAdapter.process_his_data(patient_data)
|
||||
|
||||
if result['success']:
|
||||
total_surveys_created += 1
|
||||
|
||||
patient_name = "Unknown"
|
||||
if result.get('patient'):
|
||||
patient_name = f"{result['patient'].first_name} {result['patient'].last_name}".strip()
|
||||
|
||||
if result.get('survey_sent'):
|
||||
total_surveys_sent += 1
|
||||
self.stdout.write(
|
||||
f' {self.style.SUCCESS("✓")} Survey sent to {patient_name}'
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
f' {self.style.WARNING("⚠")} Survey created but not sent to {patient_name}'
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
f' {self.style.ERROR("✗")} {result.get("message", "Unknown error")}'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
f' {self.style.ERROR("✗")} Error: {str(e)}'
|
||||
)
|
||||
|
||||
total_patients += len(patients)
|
||||
|
||||
# Update last sync
|
||||
if client.config:
|
||||
client.config.last_sync_at = timezone.now()
|
||||
client.config.save(update_fields=['last_sync_at'])
|
||||
|
||||
# Summary
|
||||
self.stdout.write(self.style.MIGRATE_HEADING('\n' + '=' * 70))
|
||||
self.stdout.write(self.style.MIGRATE_HEADING('Summary'))
|
||||
self.stdout.write(self.style.MIGRATE_HEADING('=' * 70))
|
||||
self.stdout.write(f'Total patients fetched: {total_patients}')
|
||||
self.stdout.write(f'Total surveys created: {total_surveys_created}')
|
||||
self.stdout.write(f'Total surveys sent: {total_surveys_sent}')
|
||||
|
||||
if test_only:
|
||||
self.stdout.write(self.style.SUCCESS('\nConnection test completed successfully!'))
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS('\nFetch completed!'))
|
||||
127
apps/integrations/management/commands/setup_his_integration.py
Normal file
127
apps/integrations/management/commands/setup_his_integration.py
Normal file
@ -0,0 +1,127 @@
|
||||
"""
|
||||
Management command to set up HIS integration configuration.
|
||||
|
||||
Usage:
|
||||
python manage.py setup_his_integration --name "HIS Production" --url "https://his.hospital.com/api"
|
||||
|
||||
Options:
|
||||
--name Configuration name
|
||||
--url HIS API URL
|
||||
--api-key API key for authentication
|
||||
--hospital Hospital ID to associate with this config
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from apps.integrations.models import IntegrationConfig, SourceSystem
|
||||
from apps.organizations.models import Hospital
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Set up HIS integration configuration'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--name',
|
||||
type=str,
|
||||
required=True,
|
||||
help='Configuration name (e.g., "HIS Production")'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--url',
|
||||
type=str,
|
||||
required=True,
|
||||
help='HIS API URL (e.g., "https://his.hospital.com/api/patients")'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--api-key',
|
||||
type=str,
|
||||
help='API key for authentication'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--hospital',
|
||||
type=str,
|
||||
help='Hospital code to associate with this config'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--active',
|
||||
action='store_true',
|
||||
default=True,
|
||||
help='Set configuration as active (default: True)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--force',
|
||||
action='store_true',
|
||||
help='Update existing configuration with same name'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
name = options['name']
|
||||
url = options['url']
|
||||
api_key = options.get('api_key')
|
||||
hospital_code = options.get('hospital')
|
||||
is_active = options['active']
|
||||
force = options.get('force')
|
||||
|
||||
self.stdout.write(self.style.MIGRATE_HEADING('=' * 70))
|
||||
self.stdout.write(self.style.MIGRATE_HEADING('HIS Integration Setup'))
|
||||
self.stdout.write(self.style.MIGRATE_HEADING('=' * 70))
|
||||
|
||||
# Check for existing configuration
|
||||
existing = IntegrationConfig.objects.filter(name=name).first()
|
||||
|
||||
if existing and not force:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f'Configuration "{name}" already exists. Use --force to update.')
|
||||
)
|
||||
return
|
||||
|
||||
# Build config JSON
|
||||
config_json = {}
|
||||
|
||||
if hospital_code:
|
||||
hospital = Hospital.objects.filter(code=hospital_code).first()
|
||||
if hospital:
|
||||
config_json['hospital_id'] = str(hospital.id)
|
||||
config_json['hospital_code'] = hospital_code
|
||||
self.stdout.write(f'Associated with hospital: {hospital.name}')
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f'Hospital with code "{hospital_code}" not found')
|
||||
)
|
||||
|
||||
# Create or update configuration
|
||||
if existing:
|
||||
existing.api_url = url
|
||||
if api_key:
|
||||
existing.api_key = api_key
|
||||
existing.is_active = is_active
|
||||
existing.config_json.update(config_json)
|
||||
existing.save()
|
||||
config = existing
|
||||
action = 'Updated'
|
||||
else:
|
||||
config = IntegrationConfig.objects.create(
|
||||
name=name,
|
||||
source_system=SourceSystem.HIS,
|
||||
api_url=url,
|
||||
api_key=api_key or '',
|
||||
is_active=is_active,
|
||||
config_json=config_json
|
||||
)
|
||||
action = 'Created'
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'\n✓ {action} configuration:'))
|
||||
self.stdout.write(f' Name: {config.name}')
|
||||
self.stdout.write(f' Source: {config.get_source_system_display()}')
|
||||
self.stdout.write(f' API URL: {config.api_url}')
|
||||
self.stdout.write(f' API Key: {"✓ Set" if config.api_key else "✗ Not set"}')
|
||||
self.stdout.write(f' Active: {"Yes" if config.is_active else "No"}')
|
||||
|
||||
self.stdout.write(self.style.MIGRATE_HEADING('\n' + '=' * 70))
|
||||
self.stdout.write('Next steps:')
|
||||
self.stdout.write('1. Test the connection:')
|
||||
self.stdout.write(f' python manage.py test_his_connection --config "{config.name}"')
|
||||
self.stdout.write('2. Manually fetch surveys:')
|
||||
self.stdout.write(' python manage.py fetch_his_surveys --test')
|
||||
self.stdout.write('3. The automated task will run every 5 minutes via Celery Beat')
|
||||
self.stdout.write('=' * 70)
|
||||
123
apps/integrations/management/commands/test_his_connection.py
Normal file
123
apps/integrations/management/commands/test_his_connection.py
Normal file
@ -0,0 +1,123 @@
|
||||
"""
|
||||
Management command to test HIS connection.
|
||||
|
||||
Usage:
|
||||
python manage.py test_his_connection [--config ID]
|
||||
|
||||
Options:
|
||||
--config ID Test specific configuration by ID
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from apps.integrations.models import IntegrationConfig, SourceSystem
|
||||
from apps.integrations.services.his_client import HISClient
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Test connectivity to HIS system'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--config',
|
||||
type=str,
|
||||
help='Configuration ID or name to test (optional)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--all',
|
||||
action='store_true',
|
||||
help='Test all active configurations'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
config_id = options.get('config')
|
||||
test_all = options.get('all')
|
||||
|
||||
self.stdout.write(self.style.MIGRATE_HEADING('=' * 70))
|
||||
self.stdout.write(self.style.MIGRATE_HEADING('HIS Connection Test'))
|
||||
self.stdout.write(self.style.MIGRATE_HEADING('=' * 70))
|
||||
|
||||
configs = []
|
||||
|
||||
if config_id:
|
||||
# Try to find by ID first, then by name
|
||||
config = IntegrationConfig.objects.filter(
|
||||
id=config_id,
|
||||
source_system=SourceSystem.HIS
|
||||
).first()
|
||||
|
||||
if not config:
|
||||
config = IntegrationConfig.objects.filter(
|
||||
name__icontains=config_id,
|
||||
source_system=SourceSystem.HIS
|
||||
).first()
|
||||
|
||||
if config:
|
||||
configs = [config]
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f'Configuration "{config_id}" not found')
|
||||
)
|
||||
return
|
||||
|
||||
elif test_all:
|
||||
configs = list(IntegrationConfig.objects.filter(
|
||||
source_system=SourceSystem.HIS,
|
||||
is_active=True
|
||||
))
|
||||
|
||||
else:
|
||||
# Test default (first active)
|
||||
config = IntegrationConfig.objects.filter(
|
||||
source_system=SourceSystem.HIS,
|
||||
is_active=True
|
||||
).first()
|
||||
|
||||
if config:
|
||||
configs = [config]
|
||||
else:
|
||||
# Try with no config (fallback)
|
||||
self.stdout.write(
|
||||
self.style.WARNING('No active HIS configurations found. Testing fallback...')
|
||||
)
|
||||
configs = [None]
|
||||
|
||||
if not configs:
|
||||
self.stdout.write(
|
||||
self.style.ERROR('No configurations to test')
|
||||
)
|
||||
return
|
||||
|
||||
all_passed = True
|
||||
|
||||
for config in configs:
|
||||
if config:
|
||||
self.stdout.write(
|
||||
self.style.MIGRATE_HEADING(f'\n📡 Testing: {config.name}')
|
||||
)
|
||||
self.stdout.write(f' API URL: {config.api_url or "Not set"}')
|
||||
self.stdout.write(f' API Key: {"✓ Set" if config.api_key else "✗ Not set"}')
|
||||
client = HISClient(config)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.MIGRATE_HEADING('\n📡 Testing: Fallback (no config)')
|
||||
)
|
||||
client = HISClient()
|
||||
|
||||
self.stdout.write(' Connecting...', ending=' ')
|
||||
|
||||
result = client.test_connection()
|
||||
|
||||
if result['success']:
|
||||
self.stdout.write(self.style.SUCCESS('✓ Success'))
|
||||
self.stdout.write(f' Message: {result["message"]}')
|
||||
else:
|
||||
self.stdout.write(self.style.ERROR('✗ Failed'))
|
||||
self.stdout.write(f' Error: {result["message"]}')
|
||||
all_passed = False
|
||||
|
||||
self.stdout.write(self.style.MIGRATE_HEADING('\n' + '=' * 70))
|
||||
|
||||
if all_passed:
|
||||
self.stdout.write(self.style.SUCCESS('All connection tests passed!'))
|
||||
else:
|
||||
self.stdout.write(self.style.ERROR('Some connection tests failed'))
|
||||
@ -1,3 +1,7 @@
|
||||
"""
|
||||
Integrations services package
|
||||
"""
|
||||
"""
|
||||
from .his_adapter import HISAdapter
|
||||
from .his_client import HISClient, HISClientFactory
|
||||
|
||||
__all__ = ['HISAdapter', 'HISClient', 'HISClientFactory']
|
||||
|
||||
408
apps/integrations/services/his_client.py
Normal file
408
apps/integrations/services/his_client.py
Normal file
@ -0,0 +1,408 @@
|
||||
"""
|
||||
HIS Client Service - Fetches patient data from external HIS systems
|
||||
|
||||
This service provides a client to fetch patient survey data from
|
||||
Hospital Information Systems (HIS) via their APIs.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.integrations.models import IntegrationConfig, SourceSystem
|
||||
|
||||
logger = logging.getLogger("apps.integrations")
|
||||
|
||||
|
||||
class HISClient:
|
||||
"""
|
||||
Client for fetching patient data from HIS systems.
|
||||
|
||||
This client connects to external HIS APIs and retrieves
|
||||
patient demographic and visit data for survey processing.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[IntegrationConfig] = None):
|
||||
"""
|
||||
Initialize HIS client.
|
||||
|
||||
Args:
|
||||
config: IntegrationConfig instance. If None, will try to load
|
||||
active HIS configuration from database.
|
||||
"""
|
||||
self.config = config or self._get_default_config()
|
||||
self.session = requests.Session()
|
||||
|
||||
# Load credentials from environment if no config
|
||||
self.username = os.getenv("HIS_API_USERNAME", "")
|
||||
self.password = os.getenv("HIS_API_PASSWORD", "")
|
||||
|
||||
def _get_default_config(self) -> Optional[IntegrationConfig]:
|
||||
"""Get default active HIS configuration from database."""
|
||||
try:
|
||||
return IntegrationConfig.objects.filter(source_system=SourceSystem.HIS, is_active=True).first()
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading HIS configuration: {e}")
|
||||
return None
|
||||
|
||||
def _get_headers(self) -> Dict[str, str]:
|
||||
"""Get request headers with authentication."""
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
# Support for API key if configured
|
||||
if self.config and self.config.api_key:
|
||||
headers["X-API-Key"] = self.config.api_key
|
||||
headers["Authorization"] = f"Bearer {self.config.api_key}"
|
||||
|
||||
return headers
|
||||
|
||||
def _get_api_url(self) -> Optional[str]:
|
||||
"""Get API URL from configuration or environment."""
|
||||
if not self.config:
|
||||
# Fallback to environment variable
|
||||
return os.getenv("HIS_API_URL", "")
|
||||
return self.config.api_url
|
||||
|
||||
def _get_auth(self) -> Optional[tuple]:
|
||||
"""Get Basic Auth credentials."""
|
||||
if self.username and self.password:
|
||||
return (self.username, self.password)
|
||||
return None
|
||||
|
||||
def fetch_patients(
|
||||
self, since: Optional[datetime] = None, patient_type: Optional[str] = None, limit: int = 100
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Fetch patients from HIS system.
|
||||
|
||||
Args:
|
||||
since: Only fetch patients updated since this datetime
|
||||
patient_type: Filter by patient type (1=Inpatient, 2=OPD, 3=EMS)
|
||||
limit: Maximum number of patients to fetch
|
||||
|
||||
Returns:
|
||||
List of patient data dictionaries in HIS format
|
||||
"""
|
||||
api_url = self._get_api_url()
|
||||
if not api_url:
|
||||
logger.error("No HIS API URL configured")
|
||||
return []
|
||||
|
||||
try:
|
||||
# Build URL with query parameters for the new endpoint format
|
||||
if since:
|
||||
# Calculate end time (5 minutes window since this runs every 5 minutes)
|
||||
end_time = since + timedelta(minutes=5)
|
||||
|
||||
# Format dates for HIS API: DD-Mon-YYYY HH:MM:SS
|
||||
from_date = self._format_datetime(since)
|
||||
to_date = self._format_datetime(end_time)
|
||||
|
||||
# Build URL with parameters (SSN=0 and MobileNo=0 to get all)
|
||||
url = f"{api_url}?FromDate={quote(from_date)}&ToDate={quote(to_date)}&SSN=0&MobileNo=0"
|
||||
else:
|
||||
# If no since time, use last 10 minutes as default
|
||||
end_time = timezone.now()
|
||||
start_time = end_time - timedelta(minutes=10)
|
||||
|
||||
from_date = self._format_datetime(start_time)
|
||||
to_date = self._format_datetime(end_time)
|
||||
|
||||
url = f"{api_url}?FromDate={quote(from_date)}&ToDate={quote(to_date)}&SSN=0&MobileNo=0"
|
||||
|
||||
logger.info(f"Fetching patients from HIS: {url}")
|
||||
|
||||
# Make request with Basic Auth
|
||||
response = self.session.get(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
auth=self._get_auth(),
|
||||
timeout=30,
|
||||
verify=True, # Set to False if SSL certificate issues
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse response
|
||||
data = response.json()
|
||||
|
||||
# Handle different response formats
|
||||
if isinstance(data, list):
|
||||
patients = data
|
||||
elif isinstance(data, dict):
|
||||
patients = data.get("FetchPatientDataTimeStampList", [])
|
||||
# Alternative formats
|
||||
if not patients:
|
||||
patients = data.get("patients", [])
|
||||
if not patients:
|
||||
patients = data.get("data", [])
|
||||
else:
|
||||
patients = []
|
||||
|
||||
logger.info(f"Fetched {len(patients)} patients from HIS")
|
||||
return patients
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Error fetching patients from HIS: {e}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error fetching patients: {e}")
|
||||
return []
|
||||
|
||||
# Build query parameters
|
||||
params = {"limit": limit}
|
||||
|
||||
if since:
|
||||
# Format: DD-Mon-YYYY HH:MM (HIS format)
|
||||
params["since"] = self._format_datetime(since)
|
||||
|
||||
if patient_type:
|
||||
params["patient_type"] = patient_type
|
||||
|
||||
# Additional config params from IntegrationConfig
|
||||
if self.config and self.config.config_json:
|
||||
config_params = self.config.config_json.get("fetch_params", {})
|
||||
params.update(config_params)
|
||||
|
||||
try:
|
||||
logger.info(f"Fetching patients from HIS: {api_url}")
|
||||
|
||||
response = self.session.get(api_url, headers=self._get_headers(), params=params, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
# Handle different response formats
|
||||
if isinstance(data, list):
|
||||
patients = data
|
||||
elif isinstance(data, dict):
|
||||
# Standard HIS format
|
||||
patients = data.get("FetchPatientDataTimeStampList", [])
|
||||
# Alternative formats
|
||||
if not patients:
|
||||
patients = data.get("patients", [])
|
||||
if not patients:
|
||||
patients = data.get("data", [])
|
||||
else:
|
||||
patients = []
|
||||
|
||||
logger.info(f"Fetched {len(patients)} patients from HIS")
|
||||
return patients
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Error fetching patients from HIS: {e}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error fetching patients: {e}")
|
||||
return []
|
||||
|
||||
def fetch_discharged_patients(self, since: Optional[datetime] = None, limit: int = 100) -> List[Dict]:
|
||||
"""
|
||||
Fetch discharged patients who need surveys.
|
||||
|
||||
This is the main method for survey fetching - only discharged
|
||||
patients are eligible for surveys.
|
||||
|
||||
Args:
|
||||
since: Only fetch patients discharged since this datetime
|
||||
limit: Maximum number of patients to fetch
|
||||
|
||||
Returns:
|
||||
List of patient data dictionaries in HIS format
|
||||
"""
|
||||
api_url = self._get_api_url()
|
||||
if not api_url:
|
||||
logger.error("No HIS API URL configured")
|
||||
return []
|
||||
|
||||
try:
|
||||
# Build URL with query parameters for the new endpoint format
|
||||
if since:
|
||||
# Calculate end time (5 minutes window since this runs every 5 minutes)
|
||||
end_time = since + timedelta(minutes=5)
|
||||
|
||||
# Format dates for HIS API: DD-Mon-YYYY HH:MM:SS
|
||||
from_date = self._format_datetime(since)
|
||||
to_date = self._format_datetime(end_time)
|
||||
|
||||
# Build URL with parameters (SSN=0 and MobileNo=0 to get all)
|
||||
url = f"{api_url}?FromDate={quote(from_date)}&ToDate={quote(to_date)}&SSN=0&MobileNo=0"
|
||||
else:
|
||||
# If no since time, use last 10 minutes as default
|
||||
end_time = timezone.now()
|
||||
start_time = end_time - timedelta(minutes=10)
|
||||
|
||||
from_date = self._format_datetime(start_time)
|
||||
to_date = self._format_datetime(end_time)
|
||||
|
||||
url = f"{api_url}?FromDate={quote(from_date)}&ToDate={quote(to_date)}&SSN=0&MobileNo=0"
|
||||
|
||||
logger.info(f"Fetching discharged patients from HIS: {url}")
|
||||
|
||||
# Make request with Basic Auth
|
||||
response = self.session.get(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
auth=self._get_auth(),
|
||||
timeout=30,
|
||||
verify=True, # Set to False if SSL certificate issues
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
# Parse response
|
||||
if isinstance(data, list):
|
||||
patients = data
|
||||
elif isinstance(data, dict):
|
||||
patients = data.get("FetchPatientDataTimeStampList", [])
|
||||
if not patients:
|
||||
patients = data.get("patients", [])
|
||||
if not patients:
|
||||
patients = data.get("data", [])
|
||||
else:
|
||||
patients = []
|
||||
|
||||
# Filter only discharged patients
|
||||
discharged = [
|
||||
p
|
||||
for p in patients
|
||||
if p.get("DischargeDate")
|
||||
or (isinstance(p, dict) and p.get("FetchPatientDataTimeStampList", [{}])[0].get("DischargeDate"))
|
||||
]
|
||||
|
||||
logger.info(f"Fetched {len(discharged)} discharged patients from HIS")
|
||||
return discharged
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Error fetching discharged patients: {e}")
|
||||
return []
|
||||
|
||||
def fetch_patient_visits(self, patient_id: str) -> List[Dict]:
|
||||
"""
|
||||
Fetch visit data for a specific patient.
|
||||
|
||||
Args:
|
||||
patient_id: Patient ID from HIS
|
||||
|
||||
Returns:
|
||||
List of visit data dictionaries
|
||||
"""
|
||||
api_url = self._get_api_url()
|
||||
if not api_url:
|
||||
return []
|
||||
|
||||
visits_endpoint = f"{api_url.rstrip('/')}/{patient_id}/visits"
|
||||
|
||||
try:
|
||||
response = self.session.get(visits_endpoint, headers=self._get_headers(), timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
elif isinstance(data, dict):
|
||||
return data.get("FetchPatientDataTimeStampVisitDataList", [])
|
||||
return []
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Error fetching visits for patient {patient_id}: {e}")
|
||||
return []
|
||||
|
||||
def test_connection(self) -> Dict:
|
||||
"""
|
||||
Test connectivity to HIS system.
|
||||
|
||||
Returns:
|
||||
Dict with 'success' boolean and 'message' string
|
||||
"""
|
||||
api_url = self._get_api_url()
|
||||
if not api_url:
|
||||
return {"success": False, "message": "No API URL configured"}
|
||||
|
||||
try:
|
||||
# Try to fetch patients for last 1 minute as a test
|
||||
end_time = timezone.now()
|
||||
start_time = end_time - timedelta(minutes=1)
|
||||
|
||||
from_date = self._format_datetime(start_time)
|
||||
to_date = self._format_datetime(end_time)
|
||||
|
||||
url = f"{api_url}?FromDate={quote(from_date)}&ToDate={quote(to_date)}&SSN=0&MobileNo=0"
|
||||
|
||||
response = self.session.get(
|
||||
url, headers=self._get_headers(), auth=self._get_auth(), timeout=10, verify=True
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Successfully connected to HIS",
|
||||
"config_name": self.config.name if self.config else "Default",
|
||||
}
|
||||
else:
|
||||
return {"success": False, "message": f"HIS returned status {response.status_code}"}
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
return {"success": False, "message": "Could not connect to HIS server"}
|
||||
except requests.exceptions.Timeout:
|
||||
return {"success": False, "message": "Connection to HIS timed out"}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"Error: {str(e)}"}
|
||||
|
||||
@staticmethod
|
||||
def _format_datetime(dt: datetime) -> str:
|
||||
"""Format datetime for HIS API (DD-Mon-YYYY HH:MM:SS format)."""
|
||||
months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
||||
return f"{dt.day:02d}-{months[dt.month - 1]}-{dt.year} {dt.hour:02d}:{dt.minute:02d}:{dt.second:02d}"
|
||||
|
||||
|
||||
class HISClientFactory:
|
||||
"""Factory for creating HIS clients."""
|
||||
|
||||
@staticmethod
|
||||
def get_client(hospital_id: Optional[str] = None) -> HISClient:
|
||||
"""
|
||||
Get HIS client for a specific hospital or default.
|
||||
|
||||
Args:
|
||||
hospital_id: Optional hospital ID to get specific config
|
||||
|
||||
Returns:
|
||||
HISClient instance
|
||||
"""
|
||||
config = None
|
||||
|
||||
if hospital_id:
|
||||
# Try to find config for specific hospital
|
||||
try:
|
||||
from apps.organizations.models import Hospital
|
||||
|
||||
hospital = Hospital.objects.filter(id=hospital_id).first()
|
||||
if hospital:
|
||||
config = IntegrationConfig.objects.filter(
|
||||
source_system=SourceSystem.HIS, is_active=True, config_json__hospital_id=str(hospital.id)
|
||||
).first()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return HISClient(config)
|
||||
|
||||
@staticmethod
|
||||
def get_all_active_clients() -> List[HISClient]:
|
||||
"""Get all active HIS clients for multi-hospital setups."""
|
||||
configs = IntegrationConfig.objects.filter(source_system=SourceSystem.HIS, is_active=True)
|
||||
|
||||
if not configs.exists():
|
||||
# Return default client with no config
|
||||
return [HISClient()]
|
||||
|
||||
return [HISClient(config) for config in configs]
|
||||
@ -6,14 +6,16 @@ This module contains the core event processing logic that:
|
||||
2. Finds matching journey instances
|
||||
3. Completes journey stages
|
||||
4. Triggers survey creation
|
||||
5. Fetches surveys from HIS systems (every 5 minutes)
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from celery import shared_task
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
logger = logging.getLogger('apps.integrations')
|
||||
logger = logging.getLogger("apps.integrations")
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3)
|
||||
@ -49,25 +51,25 @@ def process_inbound_event(self, event_id):
|
||||
# Find journey instance by encounter_id
|
||||
try:
|
||||
journey_instance = PatientJourneyInstance.objects.select_related(
|
||||
'journey_template', 'patient', 'hospital'
|
||||
"journey_template", "patient", "hospital"
|
||||
).get(encounter_id=event.encounter_id)
|
||||
except PatientJourneyInstance.DoesNotExist:
|
||||
error_msg = f"No journey instance found for encounter {event.encounter_id}"
|
||||
logger.warning(error_msg)
|
||||
event.mark_ignored(error_msg)
|
||||
return {'status': 'ignored', 'reason': error_msg}
|
||||
return {"status": "ignored", "reason": error_msg}
|
||||
|
||||
# Find matching stage by trigger_event_code
|
||||
matching_stages = journey_instance.stage_instances.filter(
|
||||
stage_template__trigger_event_code=event.event_code,
|
||||
status__in=[StageStatus.PENDING, StageStatus.IN_PROGRESS]
|
||||
).select_related('stage_template')
|
||||
status__in=[StageStatus.PENDING, StageStatus.IN_PROGRESS],
|
||||
).select_related("stage_template")
|
||||
|
||||
if not matching_stages.exists():
|
||||
error_msg = f"No pending stage found with trigger {event.event_code}"
|
||||
logger.warning(error_msg)
|
||||
event.mark_ignored(error_msg)
|
||||
return {'status': 'ignored', 'reason': error_msg}
|
||||
return {"status": "ignored", "reason": error_msg}
|
||||
|
||||
# Get the first matching stage
|
||||
stage_instance = matching_stages.first()
|
||||
@ -78,101 +80,88 @@ def process_inbound_event(self, event_id):
|
||||
|
||||
if event.physician_license:
|
||||
try:
|
||||
staff = Staff.objects.get(
|
||||
license_number=event.physician_license,
|
||||
hospital=journey_instance.hospital
|
||||
)
|
||||
staff = Staff.objects.get(license_number=event.physician_license, hospital=journey_instance.hospital)
|
||||
except Staff.DoesNotExist:
|
||||
logger.warning(f"Staff member not found with license: {event.physician_license}")
|
||||
|
||||
if event.department_code:
|
||||
try:
|
||||
department = Department.objects.get(
|
||||
code=event.department_code,
|
||||
hospital=journey_instance.hospital
|
||||
)
|
||||
department = Department.objects.get(code=event.department_code, hospital=journey_instance.hospital)
|
||||
except Department.DoesNotExist:
|
||||
logger.warning(f"Department not found: {event.department_code}")
|
||||
|
||||
# Complete the stage
|
||||
with transaction.atomic():
|
||||
success = stage_instance.complete(
|
||||
event=event,
|
||||
staff=staff,
|
||||
department=department,
|
||||
metadata=event.payload_json
|
||||
event=event, staff=staff, department=department, metadata=event.payload_json
|
||||
)
|
||||
|
||||
if success:
|
||||
# Log stage completion
|
||||
create_audit_log(
|
||||
event_type='stage_completed',
|
||||
event_type="stage_completed",
|
||||
description=f"Stage {stage_instance.stage_template.name} completed for encounter {event.encounter_id}",
|
||||
content_object=stage_instance,
|
||||
metadata={
|
||||
'event_code': event.event_code,
|
||||
'stage_name': stage_instance.stage_template.name,
|
||||
'journey_type': journey_instance.journey_template.journey_type
|
||||
}
|
||||
"event_code": event.event_code,
|
||||
"stage_name": stage_instance.stage_template.name,
|
||||
"journey_type": journey_instance.journey_template.journey_type,
|
||||
},
|
||||
)
|
||||
|
||||
# Check if this is a discharge event
|
||||
if event.event_code.upper() == 'PATIENT_DISCHARGED':
|
||||
if event.event_code.upper() == "PATIENT_DISCHARGED":
|
||||
logger.info(f"Discharge event received for encounter {event.encounter_id}")
|
||||
|
||||
|
||||
# Mark journey as completed
|
||||
journey_instance.status = 'completed'
|
||||
journey_instance.status = "completed"
|
||||
journey_instance.completed_at = timezone.now()
|
||||
journey_instance.save()
|
||||
|
||||
|
||||
# Check if post-discharge survey is enabled
|
||||
if journey_instance.journey_template.send_post_discharge_survey:
|
||||
logger.info(
|
||||
f"Post-discharge survey enabled for journey {journey_instance.id}. "
|
||||
f"Will send in {journey_instance.journey_template.post_discharge_survey_delay_hours} hour(s)"
|
||||
)
|
||||
|
||||
|
||||
# Queue post-discharge survey creation task with delay
|
||||
from apps.surveys.tasks import create_post_discharge_survey
|
||||
|
||||
delay_hours = journey_instance.journey_template.post_discharge_survey_delay_hours
|
||||
delay_seconds = delay_hours * 3600
|
||||
|
||||
|
||||
create_post_discharge_survey.apply_async(
|
||||
args=[str(journey_instance.id)],
|
||||
countdown=delay_seconds
|
||||
args=[str(journey_instance.id)], countdown=delay_seconds
|
||||
)
|
||||
|
||||
|
||||
logger.info(
|
||||
f"Queued post-discharge survey for journey {journey_instance.id} "
|
||||
f"(delay: {delay_hours}h)"
|
||||
f"Queued post-discharge survey for journey {journey_instance.id} (delay: {delay_hours}h)"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Post-discharge survey disabled for journey {journey_instance.id}"
|
||||
)
|
||||
logger.info(f"Post-discharge survey disabled for journey {journey_instance.id}")
|
||||
|
||||
# Mark event as processed
|
||||
event.mark_processed()
|
||||
|
||||
logger.info(
|
||||
f"Successfully processed event {event.id}: "
|
||||
f"Completed stage {stage_instance.stage_template.name}"
|
||||
f"Successfully processed event {event.id}: Completed stage {stage_instance.stage_template.name}"
|
||||
)
|
||||
|
||||
return {
|
||||
'status': 'processed',
|
||||
'stage_completed': stage_instance.stage_template.name,
|
||||
'journey_completion': journey_instance.get_completion_percentage()
|
||||
"status": "processed",
|
||||
"stage_completed": stage_instance.stage_template.name,
|
||||
"journey_completion": journey_instance.get_completion_percentage(),
|
||||
}
|
||||
else:
|
||||
error_msg = "Failed to complete stage"
|
||||
event.mark_failed(error_msg)
|
||||
return {'status': 'failed', 'reason': error_msg}
|
||||
return {"status": "failed", "reason": error_msg}
|
||||
|
||||
except InboundEvent.DoesNotExist:
|
||||
error_msg = f"Event {event_id} not found"
|
||||
logger.error(error_msg)
|
||||
return {'status': 'error', 'reason': error_msg}
|
||||
return {"status": "error", "reason": error_msg}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error processing event: {str(e)}"
|
||||
@ -197,9 +186,9 @@ def process_pending_events():
|
||||
"""
|
||||
from apps.integrations.models import InboundEvent
|
||||
|
||||
pending_events = InboundEvent.objects.filter(
|
||||
status='pending'
|
||||
).order_by('received_at')[:100] # Process max 100 at a time
|
||||
pending_events = InboundEvent.objects.filter(status="pending").order_by("received_at")[
|
||||
:100
|
||||
] # Process max 100 at a time
|
||||
|
||||
processed_count = 0
|
||||
|
||||
@ -211,4 +200,245 @@ def process_pending_events():
|
||||
if processed_count > 0:
|
||||
logger.info(f"Queued {processed_count} pending events for processing")
|
||||
|
||||
return {'queued': processed_count}
|
||||
return {"queued": processed_count}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HIS Survey Fetching Tasks
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@shared_task
|
||||
def fetch_his_surveys():
|
||||
"""
|
||||
Periodic task to fetch surveys from HIS system every 5 minutes.
|
||||
|
||||
This task:
|
||||
1. Connects to configured HIS API endpoints
|
||||
2. Fetches discharged patients for the last 5-minute window
|
||||
3. Processes each patient using HISAdapter
|
||||
4. Creates and sends surveys via SMS
|
||||
|
||||
Scheduled to run every 5 minutes via Celery Beat.
|
||||
|
||||
Returns:
|
||||
dict: Summary of fetched and processed surveys
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from apps.integrations.models import IntegrationConfig, SourceSystem
|
||||
from apps.integrations.services.his_adapter import HISAdapter
|
||||
from apps.integrations.services.his_client import HISClient, HISClientFactory
|
||||
|
||||
logger.info("Starting HIS survey fetch task")
|
||||
|
||||
result = {
|
||||
"success": False,
|
||||
"clients_processed": 0,
|
||||
"patients_fetched": 0,
|
||||
"surveys_created": 0,
|
||||
"surveys_sent": 0,
|
||||
"errors": [],
|
||||
"details": [],
|
||||
}
|
||||
|
||||
try:
|
||||
# Get all active HIS clients
|
||||
clients = HISClientFactory.get_all_active_clients()
|
||||
|
||||
if not clients:
|
||||
msg = "No active HIS configurations found"
|
||||
logger.warning(msg)
|
||||
result["errors"].append(msg)
|
||||
return result
|
||||
|
||||
# Calculate fetch window (last 5 minutes)
|
||||
# This ensures we don't miss any patients due to timing issues
|
||||
fetch_since = timezone.now() - timedelta(minutes=5)
|
||||
|
||||
logger.info(f"Fetching discharged patients since {fetch_since}")
|
||||
|
||||
for client in clients:
|
||||
client_result = {
|
||||
"config": client.config.name if client.config else "Default",
|
||||
"patients_fetched": 0,
|
||||
"surveys_created": 0,
|
||||
"surveys_sent": 0,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
try:
|
||||
# Test connection first
|
||||
connection_test = client.test_connection()
|
||||
if not connection_test["success"]:
|
||||
error_msg = f"HIS connection failed for {client_result['config']}: {connection_test['message']}"
|
||||
logger.error(error_msg)
|
||||
client_result["errors"].append(error_msg)
|
||||
result["details"].append(client_result)
|
||||
continue
|
||||
|
||||
logger.info(f"Fetching discharged patients from {client_result['config']} since {fetch_since}")
|
||||
|
||||
# Fetch discharged patients for the 5-minute window
|
||||
patients = client.fetch_discharged_patients(
|
||||
since=fetch_since,
|
||||
limit=100, # Max 100 patients per fetch
|
||||
)
|
||||
|
||||
client_result["patients_fetched"] = len(patients)
|
||||
result["patients_fetched"] += len(patients)
|
||||
|
||||
if not patients:
|
||||
logger.info(f"No new discharged patients found in {client_result['config']}")
|
||||
result["details"].append(client_result)
|
||||
continue
|
||||
|
||||
logger.info(f"Fetched {len(patients)} patients from {client_result['config']}")
|
||||
|
||||
# Process each patient
|
||||
for patient_data in patients:
|
||||
try:
|
||||
# Ensure patient data is in proper HIS format
|
||||
if isinstance(patient_data, dict):
|
||||
if "FetchPatientDataTimeStampList" not in patient_data:
|
||||
# Wrap in proper format if needed
|
||||
patient_data = {
|
||||
"FetchPatientDataTimeStampList": [patient_data],
|
||||
"FetchPatientDataTimeStampVisitDataList": [],
|
||||
"Code": 200,
|
||||
"Status": "Success",
|
||||
}
|
||||
|
||||
# Process using HISAdapter
|
||||
process_result = HISAdapter.process_his_data(patient_data)
|
||||
|
||||
if process_result["success"]:
|
||||
client_result["surveys_created"] += 1
|
||||
result["surveys_created"] += 1
|
||||
|
||||
if process_result.get("survey_sent"):
|
||||
client_result["surveys_sent"] += 1
|
||||
result["surveys_sent"] += 1
|
||||
else:
|
||||
error_msg = f"Failed to process patient: {process_result.get('message', 'Unknown error')}"
|
||||
logger.warning(error_msg)
|
||||
client_result["errors"].append(error_msg)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error processing patient data: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
client_result["errors"].append(error_msg)
|
||||
|
||||
# Update last sync timestamp
|
||||
if client.config:
|
||||
client.config.last_sync_at = timezone.now()
|
||||
client.config.save(update_fields=["last_sync_at"])
|
||||
|
||||
result["clients_processed"] += 1
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error processing HIS client {client_result['config']}: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
client_result["errors"].append(error_msg)
|
||||
|
||||
result["details"].append(client_result)
|
||||
|
||||
result["success"] = True
|
||||
|
||||
logger.info(
|
||||
f"HIS survey fetch completed: {result['clients_processed']} clients, "
|
||||
f"{result['patients_fetched']} patients, "
|
||||
f"{result['surveys_created']} surveys created, "
|
||||
f"{result['surveys_sent']} surveys sent"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Fatal error in fetch_his_surveys task: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
result["errors"].append(error_msg)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@shared_task
|
||||
def test_his_connection(config_id=None):
|
||||
"""
|
||||
Test connectivity to HIS system.
|
||||
|
||||
Args:
|
||||
config_id: Optional IntegrationConfig ID. If not provided,
|
||||
tests the default HIS configuration.
|
||||
|
||||
Returns:
|
||||
dict: Connection test results
|
||||
"""
|
||||
from apps.integrations.models import IntegrationConfig, SourceSystem
|
||||
from apps.integrations.services.his_client import HISClient
|
||||
|
||||
logger.info(f"Testing HIS connection for config_id={config_id}")
|
||||
|
||||
try:
|
||||
if config_id:
|
||||
config = IntegrationConfig.objects.filter(id=config_id, source_system=SourceSystem.HIS).first()
|
||||
if not config:
|
||||
return {"success": False, "message": f"HIS configuration with ID {config_id} not found"}
|
||||
client = HISClient(config)
|
||||
else:
|
||||
client = HISClient()
|
||||
|
||||
result = client.test_connection()
|
||||
logger.info(f"HIS connection test result: {result}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error testing HIS connection: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
return {"success": False, "message": error_msg}
|
||||
|
||||
|
||||
@shared_task
|
||||
def sync_his_survey_mappings():
|
||||
"""
|
||||
Sync survey template mappings from HIS system.
|
||||
|
||||
This task can be scheduled periodically to sync any mapping
|
||||
configurations from the HIS system.
|
||||
|
||||
Returns:
|
||||
dict: Sync results
|
||||
"""
|
||||
from apps.integrations.models import SurveyTemplateMapping
|
||||
from apps.organizations.models import Hospital
|
||||
|
||||
logger.info("Starting HIS survey mappings sync")
|
||||
|
||||
result = {"success": True, "mappings_synced": 0, "errors": []}
|
||||
|
||||
try:
|
||||
# Get all active mappings
|
||||
mappings = SurveyTemplateMapping.objects.filter(is_active=True)
|
||||
|
||||
for mapping in mappings:
|
||||
try:
|
||||
# Validate mapping
|
||||
if not mapping.survey_template:
|
||||
result["errors"].append(f"Mapping {mapping.id} has no survey template")
|
||||
continue
|
||||
|
||||
# Additional sync logic can be added here
|
||||
# e.g., fetching updated mapping rules from HIS
|
||||
|
||||
result["mappings_synced"] += 1
|
||||
|
||||
except Exception as e:
|
||||
result["errors"].append(f"Error syncing mapping {mapping.id}: {str(e)}")
|
||||
|
||||
logger.info(f"HIS survey mappings sync completed: {result['mappings_synced']} mappings")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error syncing HIS mappings: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
result["success"] = False
|
||||
result["errors"].append(error_msg)
|
||||
|
||||
return result
|
||||
|
||||
@ -171,8 +171,16 @@ def observation_list(request):
|
||||
|
||||
# Apply RBAC filters
|
||||
user = request.user
|
||||
# Get selected hospital for PX Admins (from middleware)
|
||||
selected_hospital = getattr(request, 'tenant_hospital', None)
|
||||
|
||||
if user.is_px_admin():
|
||||
pass # See all
|
||||
# PX Admins see all, but filter by selected hospital if set
|
||||
if selected_hospital:
|
||||
queryset = queryset.filter(
|
||||
Q(assigned_department__hospital=selected_hospital) |
|
||||
Q(assigned_department__isnull=True)
|
||||
)
|
||||
elif user.is_hospital_admin() and user.hospital:
|
||||
queryset = queryset.filter(
|
||||
Q(assigned_department__hospital=user.hospital) |
|
||||
|
||||
@ -71,7 +71,7 @@ class DepartmentAdmin(admin.ModelAdmin):
|
||||
@admin.register(Staff)
|
||||
class StaffAdmin(admin.ModelAdmin):
|
||||
"""Staff admin"""
|
||||
list_display = ['__str__', 'staff_type', 'job_title', 'employee_id', 'hospital', 'department', 'phone', 'report_to', 'country', 'has_user_account', 'status']
|
||||
list_display = ['__str__', 'staff_type', 'job_title', 'employee_id', 'hospital', 'department', 'email','phone', 'report_to', 'country', 'has_user_account', 'status']
|
||||
list_filter = ['status', 'hospital', 'staff_type', 'specialization', 'gender', 'country']
|
||||
search_fields = ['name', 'first_name', 'last_name', 'first_name_ar', 'last_name_ar', 'employee_id', 'license_number', 'job_title', 'phone', 'department_name', 'section']
|
||||
ordering = ['last_name', 'first_name']
|
||||
|
||||
@ -162,7 +162,7 @@ class Department(UUIDModel, TimeStampedModel):
|
||||
unique_together = [['hospital', 'code']]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.hospital.name} - {self.name}"
|
||||
return f"{self.name}"
|
||||
|
||||
|
||||
class Staff(UUIDModel, TimeStampedModel):
|
||||
|
||||
@ -4,10 +4,13 @@ from django.db.models import Q
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from django.contrib import messages
|
||||
|
||||
from apps.core.decorators import block_source_user, hospital_admin_required
|
||||
|
||||
from .models import Department, Hospital, Organization, Patient, Staff, StaffSection, StaffSubsection
|
||||
from .forms import StaffForm
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def hospital_list(request):
|
||||
"""Hospitals list view"""
|
||||
@ -50,6 +53,7 @@ def hospital_list(request):
|
||||
return render(request, 'organizations/hospital_list.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def department_list(request):
|
||||
"""Departments list view"""
|
||||
@ -102,6 +106,7 @@ def department_list(request):
|
||||
return render(request, 'organizations/department_list.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def staff_list(request):
|
||||
"""Staff list view - filtered by tenant hospital"""
|
||||
@ -192,6 +197,7 @@ def staff_list(request):
|
||||
return render(request, 'organizations/staff_list.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def organization_list(request):
|
||||
"""Organizations list view"""
|
||||
@ -239,6 +245,7 @@ def organization_list(request):
|
||||
return render(request, 'organizations/organization_list.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def organization_detail(request, pk):
|
||||
"""Organization detail view"""
|
||||
@ -266,6 +273,7 @@ def organization_detail(request, pk):
|
||||
return render(request, 'organizations/organization_detail.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def organization_create(request):
|
||||
"""Create organization view"""
|
||||
@ -308,6 +316,7 @@ def organization_create(request):
|
||||
return render(request, 'organizations/organization_form.html')
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def patient_list(request):
|
||||
"""Patients list view"""
|
||||
@ -362,6 +371,7 @@ def patient_list(request):
|
||||
return render(request, 'organizations/patient_list.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def staff_detail(request, pk):
|
||||
"""Staff detail view"""
|
||||
@ -380,6 +390,7 @@ def staff_detail(request, pk):
|
||||
return render(request, 'organizations/staff_detail.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def staff_create(request):
|
||||
"""Create staff view"""
|
||||
@ -442,6 +453,7 @@ def staff_create(request):
|
||||
return render(request, 'organizations/staff_form.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def staff_update(request, pk):
|
||||
"""Update staff view"""
|
||||
@ -499,6 +511,7 @@ def staff_update(request, pk):
|
||||
return render(request, 'organizations/staff_form.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def staff_hierarchy(request):
|
||||
"""
|
||||
@ -626,6 +639,7 @@ def staff_hierarchy(request):
|
||||
return render(request, 'organizations/staff_hierarchy.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def staff_hierarchy_d3(request):
|
||||
"""
|
||||
@ -647,6 +661,7 @@ def staff_hierarchy_d3(request):
|
||||
|
||||
# ==================== Department CRUD ====================
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def department_create(request):
|
||||
"""Create department view"""
|
||||
@ -696,6 +711,7 @@ def department_create(request):
|
||||
return render(request, 'organizations/department_form.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def department_update(request, pk):
|
||||
"""Update department view"""
|
||||
@ -729,6 +745,7 @@ def department_update(request, pk):
|
||||
return render(request, 'organizations/department_form.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def department_delete(request, pk):
|
||||
"""Delete department view"""
|
||||
@ -762,6 +779,7 @@ def department_delete(request, pk):
|
||||
|
||||
# ==================== Staff Section CRUD ====================
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def section_list(request):
|
||||
"""Sections list view"""
|
||||
@ -814,6 +832,7 @@ def section_list(request):
|
||||
return render(request, 'organizations/section_list.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def section_create(request):
|
||||
"""Create section view"""
|
||||
@ -860,6 +879,7 @@ def section_create(request):
|
||||
return render(request, 'organizations/section_form.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def section_update(request, pk):
|
||||
"""Update section view"""
|
||||
@ -898,6 +918,7 @@ def section_update(request, pk):
|
||||
return render(request, 'organizations/section_form.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def section_delete(request, pk):
|
||||
"""Delete section view"""
|
||||
@ -930,6 +951,7 @@ def section_delete(request, pk):
|
||||
|
||||
# ==================== Staff Subsection CRUD ====================
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def subsection_list(request):
|
||||
"""Subsections list view"""
|
||||
@ -989,6 +1011,7 @@ def subsection_list(request):
|
||||
return render(request, 'organizations/subsection_list.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def subsection_create(request):
|
||||
"""Create subsection view"""
|
||||
@ -1035,6 +1058,7 @@ def subsection_create(request):
|
||||
return render(request, 'organizations/subsection_form.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def subsection_update(request, pk):
|
||||
"""Update subsection view"""
|
||||
@ -1073,6 +1097,7 @@ def subsection_update(request, pk):
|
||||
return render(request, 'organizations/subsection_form.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def subsection_delete(request, pk):
|
||||
"""Delete subsection view"""
|
||||
@ -1099,6 +1124,7 @@ def subsection_delete(request, pk):
|
||||
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def patient_detail(request, pk):
|
||||
"""Patient detail view"""
|
||||
@ -1124,6 +1150,7 @@ def patient_detail(request, pk):
|
||||
return render(request, 'organizations/patient_detail.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def patient_create(request):
|
||||
"""Create patient view"""
|
||||
@ -1151,6 +1178,7 @@ def patient_create(request):
|
||||
return render(request, 'organizations/patient_form.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def patient_update(request, pk):
|
||||
"""Update patient view"""
|
||||
@ -1185,6 +1213,7 @@ def patient_update(request, pk):
|
||||
return render(request, 'organizations/patient_form.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def patient_delete(request, pk):
|
||||
"""Delete patient view"""
|
||||
|
||||
@ -176,6 +176,10 @@ class StaffViewSet(viewsets.ModelViewSet):
|
||||
queryset = super().get_queryset().select_related('hospital', 'department', 'user')
|
||||
user = self.request.user
|
||||
|
||||
# Source Users don't have access to staff management
|
||||
if user.is_source_user():
|
||||
return queryset.none()
|
||||
|
||||
# PX Admins see all staff
|
||||
if user.is_px_admin():
|
||||
return queryset
|
||||
|
||||
@ -6,11 +6,16 @@ Forms for doctor rating imports and filtering.
|
||||
from django import forms
|
||||
|
||||
from apps.organizations.models import Hospital
|
||||
from apps.core.form_mixins import HospitalFieldMixin
|
||||
|
||||
|
||||
class DoctorRatingImportForm(forms.Form):
|
||||
class DoctorRatingImportForm(HospitalFieldMixin, forms.Form):
|
||||
"""
|
||||
Form for importing doctor ratings from CSV.
|
||||
|
||||
Hospital field visibility:
|
||||
- PX Admins: See dropdown with all hospitals
|
||||
- Others: Hidden field, auto-set to user's hospital
|
||||
"""
|
||||
hospital = forms.ModelChoiceField(
|
||||
queryset=Hospital.objects.filter(status='active'),
|
||||
@ -32,18 +37,6 @@ class DoctorRatingImportForm(forms.Form):
|
||||
help_text="Number of rows to skip before the column headers (Doctor Rating Report typically has 6 header rows)"
|
||||
)
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Filter hospital choices based on user role
|
||||
if user.is_px_admin():
|
||||
self.fields['hospital'].queryset = Hospital.objects.filter(status='active')
|
||||
elif user.hospital:
|
||||
self.fields['hospital'].queryset = Hospital.objects.filter(id=user.hospital.id)
|
||||
self.fields['hospital'].initial = user.hospital
|
||||
else:
|
||||
self.fields['hospital'].queryset = Hospital.objects.none()
|
||||
|
||||
def clean_csv_file(self):
|
||||
csv_file = self.cleaned_data['csv_file']
|
||||
|
||||
|
||||
@ -51,7 +51,7 @@ def doctor_rating_import(request):
|
||||
session_key = f'doctor_rating_import_{user.id}'
|
||||
|
||||
if request.method == 'POST':
|
||||
form = DoctorRatingImportForm(user, request.POST, request.FILES)
|
||||
form = DoctorRatingImportForm(request.POST, request.FILES, user=user)
|
||||
|
||||
if form.is_valid():
|
||||
try:
|
||||
@ -215,7 +215,7 @@ def doctor_rating_import(request):
|
||||
logger.error(f"Error processing Doctor Rating CSV: {str(e)}", exc_info=True)
|
||||
messages.error(request, f"Error processing CSV: {str(e)}")
|
||||
else:
|
||||
form = DoctorRatingImportForm(user)
|
||||
form = DoctorRatingImportForm(user=user)
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
|
||||
298
apps/projects/forms.py
Normal file
298
apps/projects/forms.py
Normal file
@ -0,0 +1,298 @@
|
||||
"""
|
||||
QI Projects Forms
|
||||
|
||||
Forms for creating and managing Quality Improvement projects and tasks.
|
||||
"""
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.core.form_mixins import HospitalFieldMixin
|
||||
from apps.accounts.models import User
|
||||
from apps.organizations.models import Department, Hospital
|
||||
|
||||
from .models import QIProject, QIProjectTask
|
||||
|
||||
|
||||
class QIProjectForm(HospitalFieldMixin, forms.ModelForm):
|
||||
"""
|
||||
Form for creating and editing QI Projects.
|
||||
|
||||
Hospital field visibility:
|
||||
- PX Admins: See dropdown with all hospitals
|
||||
- Others: Hidden field, auto-set to user's hospital
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = QIProject
|
||||
fields = [
|
||||
'name', 'name_ar', 'description', 'hospital', 'department',
|
||||
'project_lead', 'team_members', 'status',
|
||||
'start_date', 'target_completion_date', 'outcome_description'
|
||||
]
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
|
||||
'placeholder': _('Project name')
|
||||
}),
|
||||
'name_ar': forms.TextInput(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
|
||||
'placeholder': _('اسم المشروع'),
|
||||
'dir': 'rtl'
|
||||
}),
|
||||
'description': forms.Textarea(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm resize-none',
|
||||
'rows': 4,
|
||||
'placeholder': _('Describe the project objectives and scope...')
|
||||
}),
|
||||
'hospital': forms.Select(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
|
||||
}),
|
||||
'department': forms.Select(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
|
||||
}),
|
||||
'project_lead': forms.Select(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
|
||||
}),
|
||||
'team_members': forms.SelectMultiple(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white h-40'
|
||||
}),
|
||||
'status': forms.Select(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
|
||||
}),
|
||||
'start_date': forms.DateInput(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
|
||||
'type': 'date'
|
||||
}),
|
||||
'target_completion_date': forms.DateInput(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
|
||||
'type': 'date'
|
||||
}),
|
||||
'outcome_description': forms.Textarea(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm resize-none',
|
||||
'rows': 3,
|
||||
'placeholder': _('Document project outcomes and results...')
|
||||
}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Filter department choices based on hospital
|
||||
hospital_id = None
|
||||
if self.data.get('hospital'):
|
||||
hospital_id = self.data.get('hospital')
|
||||
elif self.initial.get('hospital'):
|
||||
hospital_id = self.initial.get('hospital')
|
||||
elif self.instance and self.instance.pk and self.instance.hospital:
|
||||
hospital_id = self.instance.hospital.id
|
||||
elif self.user and self.user.hospital:
|
||||
hospital_id = self.user.hospital.id
|
||||
|
||||
if hospital_id:
|
||||
self.fields['department'].queryset = Department.objects.filter(
|
||||
hospital_id=hospital_id,
|
||||
status='active'
|
||||
).order_by('name')
|
||||
|
||||
# Filter user choices based on hospital
|
||||
self.fields['project_lead'].queryset = User.objects.filter(
|
||||
hospital_id=hospital_id,
|
||||
is_active=True
|
||||
).order_by('first_name', 'last_name')
|
||||
|
||||
self.fields['team_members'].queryset = User.objects.filter(
|
||||
hospital_id=hospital_id,
|
||||
is_active=True
|
||||
).order_by('first_name', 'last_name')
|
||||
else:
|
||||
self.fields['department'].queryset = Department.objects.none()
|
||||
self.fields['project_lead'].queryset = User.objects.none()
|
||||
self.fields['team_members'].queryset = User.objects.none()
|
||||
|
||||
|
||||
class QIProjectTaskForm(forms.ModelForm):
|
||||
"""
|
||||
Form for creating and editing QI Project tasks.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = QIProjectTask
|
||||
fields = ['title', 'description', 'assigned_to', 'status', 'due_date', 'order']
|
||||
widgets = {
|
||||
'title': forms.TextInput(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
|
||||
'placeholder': _('Task title')
|
||||
}),
|
||||
'description': forms.Textarea(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm resize-none',
|
||||
'rows': 3,
|
||||
'placeholder': _('Task description...')
|
||||
}),
|
||||
'assigned_to': forms.Select(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
|
||||
}),
|
||||
'status': forms.Select(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
|
||||
}),
|
||||
'due_date': forms.DateInput(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
|
||||
'type': 'date'
|
||||
}),
|
||||
'order': forms.NumberInput(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
|
||||
'min': 0
|
||||
}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.project = kwargs.pop('project', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Make order field not required (has default value of 0)
|
||||
self.fields['order'].required = False
|
||||
|
||||
# Filter assigned_to choices based on project hospital
|
||||
if self.project and self.project.hospital:
|
||||
self.fields['assigned_to'].queryset = User.objects.filter(
|
||||
hospital=self.project.hospital,
|
||||
is_active=True
|
||||
).order_by('first_name', 'last_name')
|
||||
else:
|
||||
self.fields['assigned_to'].queryset = User.objects.none()
|
||||
|
||||
|
||||
class QIProjectTemplateForm(HospitalFieldMixin, forms.ModelForm):
|
||||
"""
|
||||
Form for creating and editing QI Project templates.
|
||||
|
||||
Hospital field visibility:
|
||||
- PX Admins: See dropdown with all hospitals
|
||||
- Others: Hidden field, auto-set to user's hospital
|
||||
|
||||
Templates can be:
|
||||
- Global (hospital=None) - available to all
|
||||
- Hospital-specific - available only to that hospital
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = QIProject
|
||||
fields = [
|
||||
'name', 'name_ar', 'description', 'hospital',
|
||||
'department', 'target_completion_date'
|
||||
]
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
|
||||
'placeholder': _('Template name')
|
||||
}),
|
||||
'name_ar': forms.TextInput(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
|
||||
'placeholder': _('اسم القالب'),
|
||||
'dir': 'rtl'
|
||||
}),
|
||||
'description': forms.Textarea(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm resize-none',
|
||||
'rows': 4,
|
||||
'placeholder': _('Describe the project template...')
|
||||
}),
|
||||
'hospital': forms.Select(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
|
||||
}),
|
||||
'department': forms.Select(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
|
||||
}),
|
||||
'target_completion_date': forms.DateInput(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
|
||||
'type': 'date'
|
||||
}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Make hospital optional for templates (global templates)
|
||||
self.fields['hospital'].required = False
|
||||
self.fields['hospital'].empty_label = _('Global (All Hospitals)')
|
||||
|
||||
# Filter department choices based on hospital
|
||||
hospital_id = None
|
||||
if self.data.get('hospital'):
|
||||
hospital_id = self.data.get('hospital')
|
||||
elif self.initial.get('hospital'):
|
||||
hospital_id = self.initial.get('hospital')
|
||||
elif self.instance and self.instance.pk and self.instance.hospital:
|
||||
hospital_id = self.instance.hospital.id
|
||||
|
||||
if hospital_id:
|
||||
self.fields['department'].queryset = Department.objects.filter(
|
||||
hospital_id=hospital_id,
|
||||
status='active'
|
||||
).order_by('name')
|
||||
else:
|
||||
self.fields['department'].queryset = Department.objects.none()
|
||||
|
||||
|
||||
class ConvertToProjectForm(forms.Form):
|
||||
"""
|
||||
Form for converting a PX Action to a QI Project.
|
||||
Allows selecting a template and customizing the project details.
|
||||
"""
|
||||
|
||||
template = forms.ModelChoiceField(
|
||||
queryset=QIProject.objects.none(),
|
||||
required=False,
|
||||
empty_label=_('Blank Project'),
|
||||
label=_('Project Template'),
|
||||
widget=forms.Select(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
|
||||
})
|
||||
)
|
||||
|
||||
project_name = forms.CharField(
|
||||
max_length=200,
|
||||
label=_('Project Name'),
|
||||
widget=forms.TextInput(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
|
||||
'placeholder': _('Enter project name')
|
||||
})
|
||||
)
|
||||
|
||||
project_lead = forms.ModelChoiceField(
|
||||
queryset=User.objects.none(),
|
||||
required=True,
|
||||
label=_('Project Lead'),
|
||||
widget=forms.Select(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
|
||||
})
|
||||
)
|
||||
|
||||
target_completion_date = forms.DateField(
|
||||
required=False,
|
||||
label=_('Target Completion Date'),
|
||||
widget=forms.DateInput(attrs={
|
||||
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
|
||||
'type': 'date'
|
||||
})
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.user = kwargs.pop('user', None)
|
||||
self.action = kwargs.pop('action', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if self.user and self.user.hospital:
|
||||
# Filter templates by hospital (or global)
|
||||
from django.db.models import Q
|
||||
self.fields['template'].queryset = QIProject.objects.filter(
|
||||
Q(hospital=self.user.hospital) | Q(hospital__isnull=True),
|
||||
status='template' # We'll add this status or use metadata
|
||||
).order_by('name')
|
||||
|
||||
# Filter project lead by hospital
|
||||
self.fields['project_lead'].queryset = User.objects.filter(
|
||||
hospital=self.user.hospital,
|
||||
is_active=True
|
||||
).order_by('first_name', 'last_name')
|
||||
else:
|
||||
self.fields['template'].queryset = QIProject.objects.none()
|
||||
self.fields['project_lead'].queryset = User.objects.none()
|
||||
@ -17,16 +17,27 @@ class QIProject(UUIDModel, TimeStampedModel):
|
||||
Quality Improvement Project.
|
||||
|
||||
Tracks improvement initiatives driven by PX feedback.
|
||||
Can also serve as a template for creating new projects.
|
||||
"""
|
||||
name = models.CharField(max_length=200)
|
||||
name_ar = models.CharField(max_length=200, blank=True)
|
||||
description = models.TextField()
|
||||
|
||||
# Template flag - if True, this is a template not an active project
|
||||
is_template = models.BooleanField(
|
||||
default=False,
|
||||
db_index=True,
|
||||
help_text="If True, this is a reusable template, not an active project"
|
||||
)
|
||||
|
||||
# Organization
|
||||
hospital = models.ForeignKey(
|
||||
'organizations.Hospital',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='qi_projects'
|
||||
related_name='qi_projects',
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Null for global templates available to all hospitals"
|
||||
)
|
||||
department = models.ForeignKey(
|
||||
'organizations.Department',
|
||||
@ -44,6 +55,15 @@ class QIProject(UUIDModel, TimeStampedModel):
|
||||
related_name='led_projects'
|
||||
)
|
||||
|
||||
# Creator
|
||||
created_by = models.ForeignKey(
|
||||
'accounts.User',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='created_qi_projects'
|
||||
)
|
||||
|
||||
# Team members
|
||||
team_members = models.ManyToManyField(
|
||||
'accounts.User',
|
||||
@ -86,9 +106,12 @@ class QIProject(UUIDModel, TimeStampedModel):
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['hospital', 'status', '-created_at']),
|
||||
models.Index(fields=['is_template', 'hospital']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
if self.is_template:
|
||||
return f"[Template] {self.name}"
|
||||
return f"{self.name} ({self.status})"
|
||||
|
||||
|
||||
|
||||
@ -1,26 +1,43 @@
|
||||
"""
|
||||
QI Projects Console UI views
|
||||
|
||||
Provides full CRUD functionality for Quality Improvement projects,
|
||||
task management, and template handling.
|
||||
"""
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.core.decorators import block_source_user
|
||||
from apps.organizations.models import Hospital
|
||||
from apps.px_action_center.models import PXAction
|
||||
|
||||
from .models import QIProject
|
||||
from .forms import ConvertToProjectForm, QIProjectForm, QIProjectTaskForm, QIProjectTemplateForm
|
||||
from .models import QIProject, QIProjectTask
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def project_list(request):
|
||||
"""QI Projects list view"""
|
||||
queryset = QIProject.objects.select_related(
|
||||
"""QI Projects list view with filtering and pagination"""
|
||||
# Exclude templates from the list
|
||||
queryset = QIProject.objects.filter(is_template=False).select_related(
|
||||
'hospital', 'department', 'project_lead'
|
||||
).prefetch_related('team_members', 'related_actions')
|
||||
|
||||
# Apply RBAC filters
|
||||
user = request.user
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
# Get selected hospital for PX Admins (from middleware)
|
||||
selected_hospital = getattr(request, 'tenant_hospital', None)
|
||||
|
||||
if user.is_px_admin():
|
||||
# PX Admins see all, but filter by selected hospital if set
|
||||
if selected_hospital:
|
||||
queryset = queryset.filter(hospital=selected_hospital)
|
||||
elif user.hospital:
|
||||
queryset = queryset.filter(hospital=user.hospital)
|
||||
|
||||
# Apply filters
|
||||
@ -37,7 +54,8 @@ def project_list(request):
|
||||
if search_query:
|
||||
queryset = queryset.filter(
|
||||
Q(name__icontains=search_query) |
|
||||
Q(description__icontains=search_query)
|
||||
Q(description__icontains=search_query) |
|
||||
Q(name_ar__icontains=search_query)
|
||||
)
|
||||
|
||||
# Ordering
|
||||
@ -59,6 +77,7 @@ def project_list(request):
|
||||
'total': queryset.count(),
|
||||
'active': queryset.filter(status='active').count(),
|
||||
'completed': queryset.filter(status='completed').count(),
|
||||
'pending': queryset.filter(status='pending').count(),
|
||||
}
|
||||
|
||||
context = {
|
||||
@ -72,11 +91,12 @@ def project_list(request):
|
||||
return render(request, 'projects/project_list.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def project_detail(request, pk):
|
||||
"""QI Project detail view"""
|
||||
"""QI Project detail view with task management"""
|
||||
project = get_object_or_404(
|
||||
QIProject.objects.select_related(
|
||||
QIProject.objects.filter(is_template=False).select_related(
|
||||
'hospital', 'department', 'project_lead'
|
||||
).prefetch_related(
|
||||
'team_members', 'related_actions', 'tasks'
|
||||
@ -84,12 +104,629 @@ def project_detail(request, pk):
|
||||
pk=pk
|
||||
)
|
||||
|
||||
# Check permission
|
||||
user = request.user
|
||||
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
|
||||
messages.error(request, _("You don't have permission to view this project."))
|
||||
return redirect('projects:project_list')
|
||||
|
||||
# Get tasks
|
||||
tasks = project.tasks.all().order_by('order')
|
||||
tasks = project.tasks.all().order_by('order', 'created_at')
|
||||
|
||||
# Get related actions
|
||||
related_actions = project.related_actions.all()
|
||||
|
||||
context = {
|
||||
'project': project,
|
||||
'tasks': tasks,
|
||||
'related_actions': related_actions,
|
||||
'can_edit': user.is_px_admin() or user.is_hospital_admin or user.is_department_manager,
|
||||
}
|
||||
|
||||
return render(request, 'projects/project_detail.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def project_create(request, template_pk=None):
|
||||
"""Create a new QI Project"""
|
||||
user = request.user
|
||||
|
||||
# Check permission (PX Admin, Hospital Admin, or Department Manager)
|
||||
if not (user.is_px_admin() or user.is_hospital_admin or user.is_department_manager):
|
||||
messages.error(request, _("You don't have permission to create projects."))
|
||||
return redirect('projects:project_list')
|
||||
|
||||
# Check for template parameter (from URL or GET)
|
||||
template_id = template_pk or request.GET.get('template')
|
||||
initial_data = {}
|
||||
template = None
|
||||
|
||||
if template_id:
|
||||
try:
|
||||
template = QIProject.objects.get(pk=template_id, is_template=True)
|
||||
initial_data = {
|
||||
'name': template.name,
|
||||
'name_ar': template.name_ar,
|
||||
'description': template.description,
|
||||
'department': template.department,
|
||||
'target_completion_date': template.target_completion_date,
|
||||
}
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
initial_data['hospital'] = user.hospital
|
||||
except QIProject.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Check for PX Action parameter (convert action to project)
|
||||
action_id = request.GET.get('action')
|
||||
if action_id:
|
||||
try:
|
||||
action = PXAction.objects.get(pk=action_id)
|
||||
initial_data['name'] = f"QI Project: {action.title}"
|
||||
initial_data['description'] = action.description
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
initial_data['hospital'] = user.hospital
|
||||
else:
|
||||
initial_data['hospital'] = action.hospital
|
||||
except PXAction.DoesNotExist:
|
||||
pass
|
||||
|
||||
if request.method == 'POST':
|
||||
form = QIProjectForm(request.POST, user=user)
|
||||
|
||||
# Check for template in POST data (hidden field)
|
||||
template_id_post = request.POST.get('template_id')
|
||||
if template_id_post:
|
||||
try:
|
||||
template = QIProject.objects.get(pk=template_id_post, is_template=True)
|
||||
except QIProject.DoesNotExist:
|
||||
template = None
|
||||
|
||||
if form.is_valid():
|
||||
project = form.save(commit=False)
|
||||
project.created_by = user
|
||||
project.save()
|
||||
form.save_m2m() # Save many-to-many relationships
|
||||
|
||||
# If created from template, copy tasks
|
||||
task_count = 0
|
||||
if template:
|
||||
for template_task in template.tasks.all():
|
||||
QIProjectTask.objects.create(
|
||||
project=project,
|
||||
title=template_task.title,
|
||||
description=template_task.description,
|
||||
order=template_task.order,
|
||||
status='pending'
|
||||
)
|
||||
task_count += 1
|
||||
|
||||
# If created from action, link it
|
||||
if action_id:
|
||||
try:
|
||||
action = PXAction.objects.get(pk=action_id)
|
||||
project.related_actions.add(action)
|
||||
except PXAction.DoesNotExist:
|
||||
pass
|
||||
|
||||
if template and task_count > 0:
|
||||
messages.success(
|
||||
request,
|
||||
_('QI Project created successfully with %(count)d task(s) from template.') % {'count': task_count}
|
||||
)
|
||||
else:
|
||||
messages.success(request, _("QI Project created successfully."))
|
||||
return redirect('projects:project_detail', pk=project.pk)
|
||||
else:
|
||||
form = QIProjectForm(user=user, initial=initial_data)
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'is_create': True,
|
||||
'template': template,
|
||||
}
|
||||
|
||||
return render(request, 'projects/project_form.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def project_edit(request, pk):
|
||||
"""Edit an existing QI Project"""
|
||||
user = request.user
|
||||
project = get_object_or_404(QIProject, pk=pk, is_template=False)
|
||||
|
||||
# Check permission
|
||||
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
|
||||
messages.error(request, _("You don't have permission to edit this project."))
|
||||
return redirect('projects:project_list')
|
||||
|
||||
# Check edit permission (PX Admin, Hospital Admin, or Department Manager)
|
||||
if not (user.is_px_admin() or user.is_hospital_admin or user.is_department_manager):
|
||||
messages.error(request, _("You don't have permission to edit projects."))
|
||||
return redirect('projects:project_detail', pk=project.pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = QIProjectForm(request.POST, instance=project, user=user)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("QI Project updated successfully."))
|
||||
return redirect('projects:project_detail', pk=project.pk)
|
||||
else:
|
||||
form = QIProjectForm(instance=project, user=user)
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'project': project,
|
||||
'is_create': False,
|
||||
}
|
||||
|
||||
return render(request, 'projects/project_form.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def project_delete(request, pk):
|
||||
"""Delete a QI Project"""
|
||||
user = request.user
|
||||
project = get_object_or_404(QIProject, pk=pk, is_template=False)
|
||||
|
||||
# Check permission (only PX Admin or Hospital Admin can delete)
|
||||
if not (user.is_px_admin() or user.is_hospital_admin):
|
||||
messages.error(request, _("You don't have permission to delete projects."))
|
||||
return redirect('projects:project_detail', pk=project.pk)
|
||||
|
||||
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
|
||||
messages.error(request, _("You don't have permission to delete this project."))
|
||||
return redirect('projects:project_list')
|
||||
|
||||
if request.method == 'POST':
|
||||
project_name = project.name
|
||||
project.delete()
|
||||
messages.success(request, _('Project "%(name)s" deleted successfully.') % {'name': project_name})
|
||||
return redirect('projects:project_list')
|
||||
|
||||
context = {
|
||||
'project': project,
|
||||
}
|
||||
|
||||
return render(request, 'projects/project_delete_confirm.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def project_save_as_template(request, pk):
|
||||
"""Save an existing project and its tasks as a template"""
|
||||
user = request.user
|
||||
project = get_object_or_404(QIProject, pk=pk, is_template=False)
|
||||
|
||||
# Check permission (only PX Admin or Hospital Admin can create templates)
|
||||
if not (user.is_px_admin() or user.is_hospital_admin):
|
||||
messages.error(request, _("You don't have permission to create templates."))
|
||||
return redirect('projects:project_detail', pk=project.pk)
|
||||
|
||||
# Check hospital access
|
||||
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
|
||||
messages.error(request, _("You don't have permission to create templates from this project."))
|
||||
return redirect('projects:project_list')
|
||||
|
||||
if request.method == 'POST':
|
||||
template_name = request.POST.get('template_name', '').strip()
|
||||
template_description = request.POST.get('template_description', '').strip()
|
||||
make_global = request.POST.get('make_global') == 'on'
|
||||
|
||||
if not template_name:
|
||||
messages.error(request, _('Please provide a template name.'))
|
||||
return redirect('projects:project_save_as_template', pk=project.pk)
|
||||
|
||||
# Create template from project
|
||||
template = QIProject.objects.create(
|
||||
name=template_name,
|
||||
name_ar=template_name, # Can be edited later
|
||||
description=template_description or project.description,
|
||||
is_template=True,
|
||||
# If global, hospital is None; otherwise use project's hospital
|
||||
hospital=None if make_global else project.hospital,
|
||||
department=project.department,
|
||||
status='pending', # Default status for templates
|
||||
created_by=user
|
||||
)
|
||||
|
||||
# Copy tasks from project to template
|
||||
for task in project.tasks.all():
|
||||
QIProjectTask.objects.create(
|
||||
project=template,
|
||||
title=task.title,
|
||||
description=task.description,
|
||||
order=task.order,
|
||||
status='pending' # Reset status for template
|
||||
)
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
_('Template "%(name)s" created successfully with %(count)d task(s).') % {
|
||||
'name': template_name,
|
||||
'count': project.tasks.count()
|
||||
}
|
||||
)
|
||||
return redirect('projects:template_list')
|
||||
|
||||
context = {
|
||||
'project': project,
|
||||
'suggested_name': f"Template: {project.name}",
|
||||
}
|
||||
|
||||
return render(request, 'projects/project_save_as_template.html', context)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Task Management Views
|
||||
# =============================================================================
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def task_create(request, project_pk):
|
||||
"""Add a new task to a project"""
|
||||
user = request.user
|
||||
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
|
||||
|
||||
# Check permission
|
||||
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
|
||||
messages.error(request, _("You don't have permission to add tasks to this project."))
|
||||
return redirect('projects:project_detail', pk=project.pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = QIProjectTaskForm(request.POST, project=project)
|
||||
if form.is_valid():
|
||||
task = form.save(commit=False)
|
||||
task.project = project
|
||||
task.save()
|
||||
messages.success(request, _("Task added successfully."))
|
||||
return redirect('projects:project_detail', pk=project.pk)
|
||||
else:
|
||||
form = QIProjectTaskForm(project=project)
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'project': project,
|
||||
'is_create': True,
|
||||
}
|
||||
|
||||
return render(request, 'projects/task_form.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def task_edit(request, project_pk, task_pk):
|
||||
"""Edit an existing task"""
|
||||
user = request.user
|
||||
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
|
||||
task = get_object_or_404(QIProjectTask, pk=task_pk, project=project)
|
||||
|
||||
# Check permission
|
||||
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
|
||||
messages.error(request, _("You don't have permission to edit tasks in this project."))
|
||||
return redirect('projects:project_detail', pk=project.pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = QIProjectTaskForm(request.POST, instance=task, project=project)
|
||||
if form.is_valid():
|
||||
task = form.save()
|
||||
# If status changed to completed, set completed_date
|
||||
if task.status == 'completed' and not task.completed_date:
|
||||
from django.utils import timezone
|
||||
task.completed_date = timezone.now().date()
|
||||
task.save()
|
||||
messages.success(request, _("Task updated successfully."))
|
||||
return redirect('projects:project_detail', pk=project.pk)
|
||||
else:
|
||||
form = QIProjectTaskForm(instance=task, project=project)
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'project': project,
|
||||
'task': task,
|
||||
'is_create': False,
|
||||
}
|
||||
|
||||
return render(request, 'projects/task_form.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def task_delete(request, project_pk, task_pk):
|
||||
"""Delete a task"""
|
||||
user = request.user
|
||||
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
|
||||
task = get_object_or_404(QIProjectTask, pk=task_pk, project=project)
|
||||
|
||||
# Check permission
|
||||
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
|
||||
messages.error(request, _("You don't have permission to delete tasks in this project."))
|
||||
return redirect('projects:project_detail', pk=project.pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
task.delete()
|
||||
messages.success(request, _("Task deleted successfully."))
|
||||
return redirect('projects:project_detail', pk=project.pk)
|
||||
|
||||
context = {
|
||||
'project': project,
|
||||
'task': task,
|
||||
}
|
||||
|
||||
return render(request, 'projects/task_delete_confirm.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def task_toggle_status(request, project_pk, task_pk):
|
||||
"""Quick toggle task status between pending and completed"""
|
||||
user = request.user
|
||||
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
|
||||
task = get_object_or_404(QIProjectTask, pk=task_pk, project=project)
|
||||
|
||||
# Check permission
|
||||
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
|
||||
messages.error(request, _("You don't have permission to update tasks in this project."))
|
||||
return redirect('projects:project_detail', pk=project.pk)
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
if task.status == 'completed':
|
||||
task.status = 'pending'
|
||||
task.completed_date = None
|
||||
else:
|
||||
task.status = 'completed'
|
||||
task.completed_date = timezone.now().date()
|
||||
|
||||
task.save()
|
||||
messages.success(request, _("Task status updated."))
|
||||
return redirect('projects:project_detail', pk=project.pk)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Template Management Views
|
||||
# =============================================================================
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def template_list(request):
|
||||
"""List QI Project templates"""
|
||||
user = request.user
|
||||
|
||||
# Only admins can manage templates
|
||||
if not (user.is_px_admin() or user.is_hospital_admin):
|
||||
messages.error(request, _("You don't have permission to view templates."))
|
||||
return redirect('projects:project_list')
|
||||
|
||||
queryset = QIProject.objects.filter(is_template=True).select_related('hospital', 'department')
|
||||
|
||||
# Apply RBAC filters
|
||||
if not user.is_px_admin():
|
||||
from django.db.models import Q
|
||||
queryset = queryset.filter(
|
||||
Q(hospital=user.hospital) | Q(hospital__isnull=True)
|
||||
)
|
||||
|
||||
# Search
|
||||
search_query = request.GET.get('search')
|
||||
if search_query:
|
||||
queryset = queryset.filter(
|
||||
Q(name__icontains=search_query) |
|
||||
Q(description__icontains=search_query) |
|
||||
Q(name_ar__icontains=search_query)
|
||||
)
|
||||
|
||||
queryset = queryset.order_by('name')
|
||||
|
||||
context = {
|
||||
'templates': queryset,
|
||||
'can_create': user.is_px_admin() or user.is_hospital_admin,
|
||||
'can_edit': user.is_px_admin() or user.is_hospital_admin,
|
||||
}
|
||||
|
||||
return render(request, 'projects/template_list.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def template_detail(request, pk):
|
||||
"""View template details with tasks"""
|
||||
user = request.user
|
||||
|
||||
# Only admins can view templates
|
||||
if not (user.is_px_admin() or user.is_hospital_admin):
|
||||
messages.error(request, _("You don't have permission to view templates."))
|
||||
return redirect('projects:project_list')
|
||||
|
||||
template = get_object_or_404(
|
||||
QIProject.objects.filter(is_template=True).select_related('hospital', 'department'),
|
||||
pk=pk
|
||||
)
|
||||
|
||||
# Check permission for hospital-specific templates
|
||||
if not user.is_px_admin():
|
||||
from django.db.models import Q
|
||||
if template.hospital and template.hospital != user.hospital:
|
||||
messages.error(request, _("You don't have permission to view this template."))
|
||||
return redirect('projects:template_list')
|
||||
|
||||
# Get tasks
|
||||
tasks = template.tasks.all().order_by('order', 'created_at')
|
||||
|
||||
context = {
|
||||
'template': template,
|
||||
'tasks': tasks,
|
||||
}
|
||||
|
||||
return render(request, 'projects/template_detail.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def template_create(request):
|
||||
"""Create a new project template"""
|
||||
user = request.user
|
||||
|
||||
# Only admins can create templates
|
||||
if not (user.is_px_admin() or user.is_hospital_admin):
|
||||
messages.error(request, _("You don't have permission to create templates."))
|
||||
return redirect('projects:project_list')
|
||||
|
||||
if request.method == 'POST':
|
||||
form = QIProjectTemplateForm(request.POST, user=user)
|
||||
if form.is_valid():
|
||||
template = form.save(commit=False)
|
||||
template.is_template = True
|
||||
template.created_by = user
|
||||
template.save()
|
||||
messages.success(request, _("Project template created successfully."))
|
||||
return redirect('projects:template_list')
|
||||
else:
|
||||
form = QIProjectTemplateForm(user=user)
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'is_create': True,
|
||||
}
|
||||
|
||||
return render(request, 'projects/template_form.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def template_edit(request, pk):
|
||||
"""Edit an existing project template"""
|
||||
user = request.user
|
||||
template = get_object_or_404(QIProject, pk=pk, is_template=True)
|
||||
|
||||
# Check permission
|
||||
if not user.is_px_admin():
|
||||
if template.hospital and template.hospital != user.hospital:
|
||||
messages.error(request, _("You don't have permission to edit this template."))
|
||||
return redirect('projects:template_list')
|
||||
|
||||
if request.method == 'POST':
|
||||
form = QIProjectTemplateForm(request.POST, instance=template, user=user)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Project template updated successfully."))
|
||||
return redirect('projects:template_list')
|
||||
else:
|
||||
form = QIProjectTemplateForm(instance=template, user=user)
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'template': template,
|
||||
'is_create': False,
|
||||
}
|
||||
|
||||
return render(request, 'projects/template_form.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def template_delete(request, pk):
|
||||
"""Delete a project template"""
|
||||
user = request.user
|
||||
template = get_object_or_404(QIProject, pk=pk, is_template=True)
|
||||
|
||||
# Check permission
|
||||
if not user.is_px_admin():
|
||||
if template.hospital and template.hospital != user.hospital:
|
||||
messages.error(request, _("You don't have permission to delete this template."))
|
||||
return redirect('projects:template_list')
|
||||
|
||||
if request.method == 'POST':
|
||||
template_name = template.name
|
||||
template.delete()
|
||||
messages.success(request, _('Template "%(name)s" deleted successfully.') % {'name': template_name})
|
||||
return redirect('projects:template_list')
|
||||
|
||||
context = {
|
||||
'template': template,
|
||||
}
|
||||
|
||||
return render(request, 'projects/template_delete_confirm.html', context)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PX Action Conversion View
|
||||
# =============================================================================
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def convert_action_to_project(request, action_pk):
|
||||
"""Convert a PX Action to a QI Project"""
|
||||
user = request.user
|
||||
|
||||
# Check permission
|
||||
if not (user.is_px_admin() or user.is_hospital_admin or user.is_department_manager):
|
||||
messages.error(request, _("You don't have permission to create projects."))
|
||||
return redirect('px_action_center:action_detail', pk=action_pk)
|
||||
|
||||
action = get_object_or_404(PXAction, pk=action_pk)
|
||||
|
||||
# Check hospital access
|
||||
if not user.is_px_admin() and user.hospital and action.hospital != user.hospital:
|
||||
messages.error(request, _("You don't have permission to convert this action."))
|
||||
return redirect('px_action_center:action_detail', pk=action_pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = ConvertToProjectForm(request.POST, user=user, action=action)
|
||||
if form.is_valid():
|
||||
# Create project from template or blank
|
||||
template = form.cleaned_data.get('template')
|
||||
|
||||
if template:
|
||||
# Copy from template
|
||||
project = QIProject.objects.create(
|
||||
name=form.cleaned_data['project_name'],
|
||||
name_ar=template.name_ar,
|
||||
description=template.description,
|
||||
hospital=action.hospital,
|
||||
department=template.department,
|
||||
project_lead=form.cleaned_data['project_lead'],
|
||||
target_completion_date=form.cleaned_data['target_completion_date'],
|
||||
status='pending',
|
||||
created_by=user
|
||||
)
|
||||
# Copy tasks from template
|
||||
for template_task in template.tasks.all():
|
||||
QIProjectTask.objects.create(
|
||||
project=project,
|
||||
title=template_task.title,
|
||||
description=template_task.description,
|
||||
order=template_task.order,
|
||||
status='pending'
|
||||
)
|
||||
else:
|
||||
# Create blank project
|
||||
project = QIProject.objects.create(
|
||||
name=form.cleaned_data['project_name'],
|
||||
description=action.description,
|
||||
hospital=action.hospital,
|
||||
project_lead=form.cleaned_data['project_lead'],
|
||||
target_completion_date=form.cleaned_data['target_completion_date'],
|
||||
status='pending',
|
||||
created_by=user
|
||||
)
|
||||
|
||||
# Link to the action
|
||||
project.related_actions.add(action)
|
||||
|
||||
messages.success(request, _("PX Action converted to QI Project successfully."))
|
||||
return redirect('projects:project_detail', pk=project.pk)
|
||||
else:
|
||||
initial_data = {
|
||||
'project_name': f"QI Project: {action.title}",
|
||||
}
|
||||
form = ConvertToProjectForm(user=user, action=action, initial=initial_data)
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'action': action,
|
||||
}
|
||||
|
||||
return render(request, 'projects/convert_action.html', context)
|
||||
|
||||
@ -1,10 +1,34 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import ui_views
|
||||
|
||||
app_name = 'projects'
|
||||
|
||||
urlpatterns = [
|
||||
# UI Views
|
||||
# QI Project Views
|
||||
path('', ui_views.project_list, name='project_list'),
|
||||
path('create/', ui_views.project_create, name='project_create'),
|
||||
path('create/from-template/<uuid:template_pk>/', ui_views.project_create, name='project_create_from_template'),
|
||||
path('<uuid:pk>/', ui_views.project_detail, name='project_detail'),
|
||||
path('<uuid:pk>/edit/', ui_views.project_edit, name='project_edit'),
|
||||
path('<uuid:pk>/delete/', ui_views.project_delete, name='project_delete'),
|
||||
|
||||
# Task Management
|
||||
path('<uuid:project_pk>/tasks/add/', ui_views.task_create, name='task_create'),
|
||||
path('<uuid:project_pk>/tasks/<uuid:task_pk>/edit/', ui_views.task_edit, name='task_edit'),
|
||||
path('<uuid:project_pk>/tasks/<uuid:task_pk>/delete/', ui_views.task_delete, name='task_delete'),
|
||||
path('<uuid:project_pk>/tasks/<uuid:task_pk>/toggle/', ui_views.task_toggle_status, name='task_toggle_status'),
|
||||
|
||||
# Template Management
|
||||
path('templates/', ui_views.template_list, name='template_list'),
|
||||
path('templates/create/', ui_views.template_create, name='template_create'),
|
||||
path('templates/<uuid:pk>/', ui_views.template_detail, name='template_detail'),
|
||||
path('templates/<uuid:pk>/edit/', ui_views.template_edit, name='template_edit'),
|
||||
path('templates/<uuid:pk>/delete/', ui_views.template_delete, name='template_delete'),
|
||||
|
||||
# Save Project as Template
|
||||
path('<uuid:pk>/save-as-template/', ui_views.project_save_as_template, name='project_save_as_template'),
|
||||
|
||||
# PX Action Conversion
|
||||
path('convert-action/<uuid:action_pk>/', ui_views.convert_action_to_project, name='convert_action'),
|
||||
]
|
||||
|
||||
@ -3,8 +3,10 @@ PX Action Center UI views - Server-rendered templates for action center console
|
||||
"""
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import Q, Prefetch
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.http import require_http_methods
|
||||
@ -43,8 +45,13 @@ def action_list(request):
|
||||
|
||||
# Apply RBAC filters
|
||||
user = request.user
|
||||
# Get selected hospital for PX Admins (from middleware)
|
||||
selected_hospital = getattr(request, 'tenant_hospital', None)
|
||||
|
||||
if user.is_px_admin():
|
||||
pass # See all
|
||||
# PX Admins see all, but filter by selected hospital if set
|
||||
if selected_hospital:
|
||||
queryset = queryset.filter(hospital=selected_hospital)
|
||||
elif user.is_hospital_admin() and user.hospital:
|
||||
queryset = queryset.filter(hospital=user.hospital)
|
||||
elif user.is_department_manager() and user.department:
|
||||
@ -542,3 +549,116 @@ def action_create(request):
|
||||
}
|
||||
|
||||
return render(request, 'actions/action_create.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["POST"])
|
||||
def action_create_from_ai(request, complaint_id):
|
||||
"""
|
||||
Create a PX Action automatically from AI suggestion.
|
||||
|
||||
Creates action in background and returns JSON response.
|
||||
Links action to the complaint automatically.
|
||||
"""
|
||||
from apps.complaints.models import Complaint
|
||||
import json
|
||||
|
||||
complaint = get_object_or_404(Complaint, id=complaint_id)
|
||||
|
||||
# Check permission
|
||||
user = request.user
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'You do not have permission to create actions.'
|
||||
}, status=403)
|
||||
|
||||
# Get action data from POST
|
||||
action_text = request.POST.get('action', '')
|
||||
priority = request.POST.get('priority', 'medium')
|
||||
category = request.POST.get('category', 'process_improvement')
|
||||
|
||||
if not action_text:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Action description is required.'
|
||||
}, status=400)
|
||||
|
||||
try:
|
||||
# Map priority/severity
|
||||
priority_map = {
|
||||
'high': 'high',
|
||||
'medium': 'medium',
|
||||
'low': 'low'
|
||||
}
|
||||
|
||||
# Create action
|
||||
action = PXAction.objects.create(
|
||||
source_type='complaint',
|
||||
content_type=ContentType.objects.get_for_model(Complaint),
|
||||
object_id=complaint.id,
|
||||
title=f"AI Suggested Action - {complaint.reference_number}",
|
||||
description=action_text,
|
||||
hospital=complaint.hospital,
|
||||
department=complaint.department,
|
||||
category=category,
|
||||
priority=priority_map.get(priority, 'medium'),
|
||||
severity=priority_map.get(priority, 'medium'),
|
||||
status=ActionStatus.OPEN,
|
||||
assigned_to=complaint.assigned_to, # Assign to same person as complaint
|
||||
assigned_at=timezone.now() if complaint.assigned_to else None,
|
||||
)
|
||||
|
||||
# Set due date based on priority (SLA)
|
||||
from datetime import timedelta
|
||||
due_days = {
|
||||
'high': 3,
|
||||
'medium': 7,
|
||||
'low': 14
|
||||
}
|
||||
action.due_at = timezone.now() + timedelta(days=due_days.get(priority, 7))
|
||||
action.save()
|
||||
|
||||
# Create log
|
||||
PXActionLog.objects.create(
|
||||
action=action,
|
||||
log_type='status_change',
|
||||
message=f"Action created automatically from AI suggestion for complaint {complaint.reference_number}",
|
||||
)
|
||||
|
||||
# Audit log
|
||||
AuditService.log_event(
|
||||
event_type='action_created',
|
||||
description=f"Action created automatically from AI suggestion",
|
||||
user=user,
|
||||
content_object=action,
|
||||
metadata={
|
||||
'complaint_id': str(complaint.id),
|
||||
'complaint_reference': complaint.reference_number,
|
||||
'source': 'ai_suggestion'
|
||||
}
|
||||
)
|
||||
|
||||
# Notify assigned user
|
||||
if action.assigned_to:
|
||||
from apps.notifications.services import NotificationService
|
||||
NotificationService.send_notification(
|
||||
recipient=action.assigned_to,
|
||||
title=f"New Action from AI: {action.title}",
|
||||
message=f"AI suggested a new action for complaint {complaint.reference_number}. Due: {action.due_at.strftime('%Y-%m-%d')}",
|
||||
notification_type='action_assigned',
|
||||
metadata={'link': f"/actions/{action.id}/"}
|
||||
)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'action_id': str(action.id),
|
||||
'action_url': f"/actions/{action.id}/",
|
||||
'message': 'PX Action created successfully from AI suggestion.'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
@ -15,6 +15,7 @@ urlpatterns = [
|
||||
# UI Views
|
||||
path('', ui_views.action_list, name='action_list'),
|
||||
path('create/', ui_views.action_create, name='action_create'),
|
||||
path('create-from-ai/<uuid:complaint_id>/', ui_views.action_create_from_ai, name='action_create_from_ai'),
|
||||
path('<uuid:pk>/', ui_views.action_detail, name='action_detail'),
|
||||
path('<uuid:pk>/assign/', ui_views.action_assign, name='action_assign'),
|
||||
path('<uuid:pk>/change-status/', ui_views.action_change_status, name='action_change_status'),
|
||||
|
||||
@ -55,6 +55,10 @@ class PXActionViewSet(viewsets.ModelViewSet):
|
||||
|
||||
user = self.request.user
|
||||
|
||||
# Source Users don't have access to PX Actions
|
||||
if user.is_source_user():
|
||||
return queryset.none()
|
||||
|
||||
# PX Admins see all actions
|
||||
if user.is_px_admin():
|
||||
return queryset
|
||||
|
||||
174
apps/px_sources/decorators.py
Normal file
174
apps/px_sources/decorators.py
Normal file
@ -0,0 +1,174 @@
|
||||
"""
|
||||
Custom decorators for PX Source User access control.
|
||||
|
||||
Provides decorators to:
|
||||
- Restrict views to source users only
|
||||
- Block source users from admin pages
|
||||
"""
|
||||
from functools import wraps
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.shortcuts import redirect
|
||||
from django.contrib import messages
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
def source_user_required(view_func):
|
||||
"""
|
||||
Decorator to restrict access to source users only.
|
||||
|
||||
Use this on views that should ONLY be accessible to source users.
|
||||
Blocks all admin/management/staff pages.
|
||||
|
||||
Example:
|
||||
@source_user_required
|
||||
def source_user_dashboard(request):
|
||||
# Only source users can access this
|
||||
"""
|
||||
@wraps(view_func)
|
||||
@login_required
|
||||
def _wrapped_view(request, *args, **kwargs):
|
||||
# Check if user has source user profile
|
||||
if not hasattr(request.user, 'source_user_profile'):
|
||||
raise PermissionDenied(
|
||||
_("Access denied. Source user privileges required.")
|
||||
)
|
||||
|
||||
source_user = request.user.source_user_profile
|
||||
|
||||
# Check if source user is active
|
||||
if not source_user.is_active:
|
||||
# Log out the user
|
||||
from django.contrib.auth import logout
|
||||
logout(request)
|
||||
messages.error(
|
||||
request,
|
||||
_("Your source user account is inactive. Please contact your administrator.")
|
||||
)
|
||||
return redirect('accounts:login')
|
||||
|
||||
# Store source_user and source in request for easy access
|
||||
request.source_user = source_user
|
||||
request.source = source_user.source
|
||||
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
return _wrapped_view
|
||||
|
||||
|
||||
def block_source_user(view_func):
|
||||
"""
|
||||
Decorator to BLOCK source users from accessing admin pages.
|
||||
|
||||
Use this on admin/management views that source users should NOT access.
|
||||
Allows all other users (PX Admin, Hospital Admin, Department Manager, etc.)
|
||||
|
||||
Example:
|
||||
@block_source_user
|
||||
def source_list(request):
|
||||
# Source users CANNOT access this
|
||||
"""
|
||||
@wraps(view_func)
|
||||
@login_required
|
||||
def _wrapped_view(request, *args, **kwargs):
|
||||
# Check if user is ONLY a source user (no other admin roles)
|
||||
if _is_pure_source_user(request.user):
|
||||
messages.error(
|
||||
request,
|
||||
_("Access denied. Source users cannot access admin pages.")
|
||||
)
|
||||
return redirect('px_sources:source_user_dashboard')
|
||||
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
return _wrapped_view
|
||||
|
||||
|
||||
def source_user_or_admin(view_func):
|
||||
"""
|
||||
Decorator that allows both source users AND admins.
|
||||
|
||||
Use this on views that should be accessible to:
|
||||
- Source users (for their own data)
|
||||
- PX Admins, Hospital Admins, Department Managers
|
||||
|
||||
Example:
|
||||
@source_user_or_admin
|
||||
def complaint_detail(request, pk):
|
||||
# Both source users and admins can view complaints
|
||||
"""
|
||||
@wraps(view_func)
|
||||
@login_required
|
||||
def _wrapped_view(request, *args, **kwargs):
|
||||
# Allow if user has admin roles
|
||||
if _has_admin_role(request.user):
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
# Allow if user is an active source user
|
||||
if hasattr(request.user, 'source_user_profile'):
|
||||
source_user = request.user.source_user_profile
|
||||
if source_user.is_active:
|
||||
request.source_user = source_user
|
||||
request.source = source_user.source
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
# Deny access
|
||||
raise PermissionDenied(_("Access denied."))
|
||||
|
||||
return _wrapped_view
|
||||
|
||||
|
||||
def _is_pure_source_user(user):
|
||||
"""
|
||||
Check if user is ONLY a source user with no admin roles.
|
||||
|
||||
Returns True if:
|
||||
- User is in 'PX Source User' group
|
||||
- User is NOT in any admin groups
|
||||
"""
|
||||
admin_groups = ['PX Admin', 'Hospital Admin', 'Department Manager']
|
||||
|
||||
# Check if user has any admin roles
|
||||
has_admin_role = user.groups.filter(name__in=admin_groups).exists()
|
||||
|
||||
if has_admin_role:
|
||||
return False
|
||||
|
||||
# Check if user is a source user
|
||||
has_source_user_profile = hasattr(user, 'source_user_profile')
|
||||
|
||||
if has_source_user_profile:
|
||||
source_user = user.source_user_profile
|
||||
if source_user.is_active:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _has_admin_role(user):
|
||||
"""
|
||||
Check if user has any admin role.
|
||||
"""
|
||||
admin_groups = ['PX Admin', 'Hospital Admin', 'Department Manager']
|
||||
return user.groups.filter(name__in=admin_groups).exists()
|
||||
|
||||
|
||||
def px_admin_required(view_func):
|
||||
"""
|
||||
Decorator to restrict access to PX Admins only.
|
||||
|
||||
Use this on views that should ONLY be accessible to PX Admins.
|
||||
"""
|
||||
@wraps(view_func)
|
||||
@login_required
|
||||
def _wrapped_view(request, *args, **kwargs):
|
||||
if not user.is_px_admin():
|
||||
messages.error(
|
||||
request,
|
||||
_("Access denied. PX Admin privileges required.")
|
||||
)
|
||||
return redirect('dashboard:dashboard')
|
||||
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
return _wrapped_view
|
||||
1
apps/px_sources/management/__init__.py
Normal file
1
apps/px_sources/management/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Management package
|
||||
1
apps/px_sources/management/commands/__init__.py
Normal file
1
apps/px_sources/management/commands/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Commands package
|
||||
@ -0,0 +1,96 @@
|
||||
"""
|
||||
Setup Source User permissions and groups.
|
||||
|
||||
Creates a dedicated 'PX Source User' group with limited permissions
|
||||
to ensure source users can only access their designated features.
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Setup Source User permissions and groups for access control'
|
||||
|
||||
def handle(self, **options):
|
||||
self.stdout.write(self.style.NOTICE('Setting up Source User permissions...'))
|
||||
|
||||
# Create Source User group
|
||||
source_user_group, created = Group.objects.get_or_create(name='PX Source User')
|
||||
|
||||
if created:
|
||||
self.stdout.write(self.style.SUCCESS('✓ Created "PX Source User" group'))
|
||||
else:
|
||||
self.stdout.write(self.style.NOTICE('✓ Found existing "PX Source User" group'))
|
||||
|
||||
# Get permissions for Complaint and Inquiry
|
||||
permissions = []
|
||||
permission_names = []
|
||||
|
||||
# Complaint permissions (only create and view their own)
|
||||
complaint_perms = [
|
||||
('complaints', 'add_complaint'),
|
||||
('complaints', 'view_complaint'),
|
||||
('complaints', 'change_complaint'),
|
||||
]
|
||||
|
||||
# Inquiry permissions
|
||||
inquiry_perms = [
|
||||
('complaints', 'add_inquiry'),
|
||||
('complaints', 'view_inquiry'),
|
||||
('complaints', 'change_inquiry'),
|
||||
]
|
||||
|
||||
# Collect all permissions
|
||||
all_perms = complaint_perms + inquiry_perms
|
||||
|
||||
for app_label, codename in all_perms:
|
||||
try:
|
||||
perm = Permission.objects.get(
|
||||
content_type__app_label=app_label,
|
||||
codename=codename
|
||||
)
|
||||
permissions.append(perm)
|
||||
permission_names.append(f"{app_label}.{codename}")
|
||||
except Permission.DoesNotExist:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f'⚠ Permission {app_label}.{codename} not found')
|
||||
)
|
||||
|
||||
# Set permissions for the group
|
||||
source_user_group.permissions.set(permissions)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'✓ Assigned {len(permissions)} permissions to "PX Source User" group'
|
||||
)
|
||||
)
|
||||
|
||||
# List assigned permissions
|
||||
self.stdout.write(self.style.NOTICE('\nAssigned permissions:'))
|
||||
for perm_name in permission_names:
|
||||
self.stdout.write(f' - {perm_name}')
|
||||
|
||||
# Create a warning about what source users CANNOT do
|
||||
self.stdout.write(self.style.WARNING('\n⚠ Source users are RESTRICTED from:'))
|
||||
self.stdout.write(' - Admin pages (/admin/)')
|
||||
self.stdout.write(' - Analytics dashboards')
|
||||
self.stdout.write(' - Configuration settings')
|
||||
self.stdout.write(' - Staff management')
|
||||
self.stdout.write(' - Source management (creating/editing sources)')
|
||||
self.stdout.write(' - User management')
|
||||
self.stdout.write(' - Onboarding management')
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
'\n✅ Source User permissions setup complete!\n'
|
||||
)
|
||||
)
|
||||
|
||||
# Instructions for assigning users to the group
|
||||
self.stdout.write(self.style.NOTICE('To assign a user to the Source User group:'))
|
||||
self.stdout.write(' 1. Go to Admin → Authentication and Authorization → Users')
|
||||
self.stdout.write(' 2. Select the user')
|
||||
self.stdout.write(' 3. Add "PX Source User" to their groups')
|
||||
self.stdout.write(' 4. Also create a SourceUser profile for them in PX Sources')
|
||||
145
apps/px_sources/middleware.py
Normal file
145
apps/px_sources/middleware.py
Normal file
@ -0,0 +1,145 @@
|
||||
"""
|
||||
Middleware for PX Source User access restriction.
|
||||
|
||||
Provides global route-level protection to ensure source users
|
||||
can only access their designated pages.
|
||||
"""
|
||||
from django.urls import resolve
|
||||
from django.shortcuts import redirect
|
||||
from django.contrib import messages
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
|
||||
|
||||
class SourceUserRestrictionMiddleware(MiddlewareMixin):
|
||||
"""
|
||||
STRICT middleware that restricts source users to ONLY:
|
||||
1. /px-sources/* pages (their dashboard, complaints, inquiries)
|
||||
2. Password change page
|
||||
3. Logout
|
||||
|
||||
ALL other routes are BLOCKED.
|
||||
"""
|
||||
|
||||
# URL path prefixes that source users CAN access (whitelist)
|
||||
ALLOWED_PATH_PREFIXES = [
|
||||
'/px-sources/', # Source user portal
|
||||
]
|
||||
|
||||
# Specific URL names that source users CAN access
|
||||
ALLOWED_URL_NAMES = {
|
||||
# Password change
|
||||
'accounts:password_change',
|
||||
'accounts:password_change_done',
|
||||
# Settings (limited)
|
||||
'accounts:settings',
|
||||
# Logout
|
||||
'accounts:logout',
|
||||
# Login (for redirect after logout)
|
||||
'accounts:login',
|
||||
# Static files (for CSS/JS)
|
||||
None, # Static files don't have URL names
|
||||
}
|
||||
|
||||
# Explicitly blocked paths (even if they match allowed prefixes)
|
||||
BLOCKED_PATHS = [
|
||||
'/px-sources/new/',
|
||||
'/px-sources/create/',
|
||||
'/px-sources/<uuid:pk>/edit/',
|
||||
'/px-sources/<uuid:pk>/delete/',
|
||||
'/px-sources/<uuid:pk>/toggle/',
|
||||
'/px-sources/ajax/',
|
||||
'/px-sources/api/',
|
||||
]
|
||||
|
||||
def process_request(self, request):
|
||||
# Skip for unauthenticated users
|
||||
if not request.user.is_authenticated:
|
||||
return None
|
||||
|
||||
# Skip for superusers
|
||||
if request.user.is_superuser:
|
||||
return None
|
||||
|
||||
# Check if user is a source user
|
||||
if not self._is_source_user(request.user):
|
||||
return None
|
||||
|
||||
# Source user detected - apply strict restrictions
|
||||
path = request.path
|
||||
|
||||
# Get current route name
|
||||
try:
|
||||
resolver = resolve(path)
|
||||
route_name = f"{resolver.namespace}:{resolver.url_name}" if resolver.namespace else resolver.url_name
|
||||
except:
|
||||
route_name = None
|
||||
|
||||
# Check if URL name is explicitly allowed
|
||||
if route_name in self.ALLOWED_URL_NAMES:
|
||||
return None
|
||||
|
||||
# Check if path starts with allowed prefixes
|
||||
for prefix in self.ALLOWED_PATH_PREFIXES:
|
||||
if path.startswith(prefix):
|
||||
# Check if it's a blocked sub-path
|
||||
for blocked in self.BLOCKED_PATHS:
|
||||
if blocked in path:
|
||||
return self._block_access(request)
|
||||
# Path is allowed
|
||||
return None
|
||||
|
||||
# Check for static/media files (allow these)
|
||||
if path.startswith('/static/') or path.startswith('/media/'):
|
||||
return None
|
||||
|
||||
# Check for i18n URLs
|
||||
if path.startswith('/i18n/'):
|
||||
return None
|
||||
|
||||
# Everything else is BLOCKED for source users
|
||||
return self._block_access(request)
|
||||
|
||||
def _is_source_user(self, user):
|
||||
"""Check if user is an active source user."""
|
||||
if not hasattr(user, 'source_user_profile'):
|
||||
return False
|
||||
|
||||
source_user = user.source_user_profile
|
||||
return source_user.is_active
|
||||
|
||||
def _block_access(self, request):
|
||||
"""Block access and redirect to source user dashboard."""
|
||||
return redirect('px_sources:source_user_dashboard')
|
||||
|
||||
|
||||
class SourceUserSessionMiddleware(MiddlewareMixin):
|
||||
"""
|
||||
Middleware to set shorter session timeout for source users.
|
||||
|
||||
Source users have limited access, so their sessions expire faster
|
||||
for security purposes.
|
||||
"""
|
||||
|
||||
SOURCE_USER_SESSION_TIMEOUT = 3600 # 1 hour
|
||||
NORMAL_SESSION_TIMEOUT = 1209600 # 2 weeks
|
||||
|
||||
def process_request(self, request):
|
||||
if not request.user.is_authenticated:
|
||||
return None
|
||||
|
||||
if self._is_source_user(request.user):
|
||||
# Set shorter session for source users
|
||||
request.session.set_expiry(self.SOURCE_USER_SESSION_TIMEOUT)
|
||||
else:
|
||||
# Normal session for other users
|
||||
request.session.set_expiry(self.NORMAL_SESSION_TIMEOUT)
|
||||
|
||||
return None
|
||||
|
||||
def _is_source_user(self, user):
|
||||
"""Check if user is an active source user."""
|
||||
if not hasattr(user, 'source_user_profile'):
|
||||
return False
|
||||
|
||||
source_user = user.source_user_profile
|
||||
return source_user.is_active
|
||||
159
apps/px_sources/permissions.py
Normal file
159
apps/px_sources/permissions.py
Normal file
@ -0,0 +1,159 @@
|
||||
"""
|
||||
DRF Permissions for PX Source User access control.
|
||||
|
||||
Provides permission classes to restrict API access based on user role:
|
||||
- Source users can only access their own data
|
||||
- Admins have full access
|
||||
"""
|
||||
from rest_framework import permissions
|
||||
from apps.px_sources.models import SourceUser
|
||||
|
||||
|
||||
class IsSourceUser(permissions.BasePermission):
|
||||
"""
|
||||
Allow access only to active source users.
|
||||
|
||||
Use this on views that should ONLY be accessible to source users.
|
||||
|
||||
Example:
|
||||
permission_classes = [IsSourceUser]
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Check if user has source user profile
|
||||
if not hasattr(request.user, 'source_user_profile'):
|
||||
return False
|
||||
|
||||
# Check if source user is active
|
||||
source_user = request.user.source_user_profile
|
||||
return source_user.is_active
|
||||
|
||||
|
||||
class IsSourceUserOrAdmin(permissions.BasePermission):
|
||||
"""
|
||||
Allow access to source users AND admins.
|
||||
|
||||
Use this on views that should be accessible to:
|
||||
- Active source users
|
||||
- PX Admins, Hospital Admins, Department Managers
|
||||
|
||||
Example:
|
||||
permission_classes = [IsSourceUserOrAdmin]
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Allow admins
|
||||
if request.user.is_superuser or request.user.is_px_admin():
|
||||
return True
|
||||
|
||||
# Allow hospital admins
|
||||
if request.user.is_hospital_admin():
|
||||
return True
|
||||
|
||||
# Allow active source users
|
||||
if hasattr(request.user, 'source_user_profile'):
|
||||
source_user = request.user.source_user_profile
|
||||
return source_user.is_active
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class IsSourceUserOwnData(permissions.BasePermission):
|
||||
"""
|
||||
Object-level permission to ensure source users can only access their own data.
|
||||
|
||||
Use this on detail views to ensure source users can only view/edit
|
||||
complaints and inquiries from their assigned source.
|
||||
|
||||
Example:
|
||||
permission_classes = [IsSourceUserOrAdmin, IsSourceUserOwnData]
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Admins can access everything
|
||||
if request.user.is_superuser or request.user.is_px_admin():
|
||||
return True
|
||||
|
||||
# Check if user is a source user
|
||||
if not hasattr(request.user, 'source_user_profile'):
|
||||
return False
|
||||
|
||||
source_user = request.user.source_user_profile
|
||||
|
||||
if not source_user.is_active:
|
||||
return False
|
||||
|
||||
# Check if object belongs to user's source
|
||||
if hasattr(obj, 'source'):
|
||||
return obj.source == source_user.source
|
||||
|
||||
# If object doesn't have a source field, deny access
|
||||
return False
|
||||
|
||||
|
||||
class IsAdminUser(permissions.BasePermission):
|
||||
"""
|
||||
Allow access only to admin users (not source users).
|
||||
|
||||
Use this on admin-only API endpoints.
|
||||
|
||||
Example:
|
||||
permission_classes = [IsAdminUser]
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Block pure source users
|
||||
if _is_pure_source_user(request.user):
|
||||
return False
|
||||
|
||||
# Allow admins
|
||||
return request.user.is_superuser or request.user.is_px_admin() or request.user.is_hospital_admin()
|
||||
|
||||
|
||||
class IsPXAdmin(permissions.BasePermission):
|
||||
"""
|
||||
Allow access only to PX Admins.
|
||||
|
||||
Use this on PX Admin-only API endpoints.
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
return request.user.is_px_admin()
|
||||
|
||||
|
||||
def _is_pure_source_user(user):
|
||||
"""
|
||||
Check if user is ONLY a source user with no admin roles.
|
||||
"""
|
||||
admin_groups = ['PX Admin', 'Hospital Admin', 'Department Manager']
|
||||
|
||||
# Check if user has any admin roles
|
||||
has_admin_role = user.groups.filter(name__in=admin_groups).exists()
|
||||
|
||||
if has_admin_role:
|
||||
return False
|
||||
|
||||
# Check if user is a source user
|
||||
has_source_user_profile = hasattr(user, 'source_user_profile')
|
||||
|
||||
if has_source_user_profile:
|
||||
source_user = user.source_user_profile
|
||||
if source_user.is_active:
|
||||
return True
|
||||
|
||||
return False
|
||||
@ -9,7 +9,9 @@ from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .models import PXSource, SourceUser
|
||||
from .decorators import source_user_required, block_source_user
|
||||
from apps.accounts.models import User
|
||||
from apps.complaints.models import Complaint, Inquiry
|
||||
|
||||
|
||||
def check_source_permission(user):
|
||||
@ -18,6 +20,7 @@ def check_source_permission(user):
|
||||
|
||||
|
||||
@login_required
|
||||
@block_source_user
|
||||
def source_list(request):
|
||||
"""
|
||||
List all PX sources
|
||||
@ -51,6 +54,7 @@ def source_list(request):
|
||||
|
||||
|
||||
@login_required
|
||||
@block_source_user
|
||||
def source_detail(request, pk):
|
||||
"""
|
||||
View source details
|
||||
@ -82,6 +86,7 @@ def source_detail(request, pk):
|
||||
|
||||
|
||||
@login_required
|
||||
@block_source_user
|
||||
def source_create(request):
|
||||
"""
|
||||
Create a new PX source
|
||||
@ -118,6 +123,7 @@ def source_create(request):
|
||||
|
||||
|
||||
@login_required
|
||||
@block_source_user
|
||||
def source_edit(request, pk):
|
||||
"""
|
||||
Edit an existing PX source
|
||||
@ -155,6 +161,7 @@ def source_edit(request, pk):
|
||||
|
||||
|
||||
@login_required
|
||||
@block_source_user
|
||||
def source_delete(request, pk):
|
||||
"""
|
||||
Delete a PX source
|
||||
@ -179,30 +186,45 @@ def source_delete(request, pk):
|
||||
|
||||
|
||||
@login_required
|
||||
@block_source_user
|
||||
def source_toggle_status(request, pk):
|
||||
"""
|
||||
Toggle source active status (AJAX)
|
||||
Toggle source active status (supports both AJAX and regular form submission)
|
||||
"""
|
||||
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
|
||||
return JsonResponse({'error': 'Permission denied'}, status=403)
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return JsonResponse({'error': 'Permission denied'}, status=403)
|
||||
messages.error(request, _("You don't have permission to toggle source status."))
|
||||
return redirect('px_sources:source_list')
|
||||
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'error': 'Method not allowed'}, status=405)
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return JsonResponse({'error': 'Method not allowed'}, status=405)
|
||||
messages.error(request, _("Invalid request method."))
|
||||
return redirect('px_sources:source_list')
|
||||
|
||||
source = get_object_or_404(PXSource, pk=pk)
|
||||
source.is_active = not source.is_active
|
||||
source.save()
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'is_active': source.is_active,
|
||||
'message': 'Source {} successfully'.format(
|
||||
'activated' if source.is_active else 'deactivated'
|
||||
)
|
||||
})
|
||||
status_text = _('activated') if source.is_active else _('deactivated')
|
||||
success_message = _("Source '{}' {} successfully.").format(source.name_en, status_text)
|
||||
|
||||
# Handle AJAX request
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'is_active': source.is_active,
|
||||
'message': str(success_message)
|
||||
})
|
||||
|
||||
# Handle regular form submission
|
||||
messages.success(request, success_message)
|
||||
return redirect('px_sources:source_list')
|
||||
|
||||
|
||||
@login_required
|
||||
@block_source_user
|
||||
def ajax_search_sources(request):
|
||||
"""
|
||||
AJAX endpoint for searching sources
|
||||
@ -234,10 +256,11 @@ def ajax_search_sources(request):
|
||||
|
||||
|
||||
@login_required
|
||||
@source_user_required
|
||||
def source_user_dashboard(request):
|
||||
"""
|
||||
Dashboard for source users.
|
||||
|
||||
|
||||
Shows:
|
||||
- User's assigned source
|
||||
- Statistics (complaints, inquiries from their source)
|
||||
@ -246,35 +269,35 @@ def source_user_dashboard(request):
|
||||
"""
|
||||
# Get source user profile
|
||||
source_user = SourceUser.get_active_source_user(request.user)
|
||||
|
||||
|
||||
if not source_user:
|
||||
messages.error(
|
||||
request,
|
||||
request,
|
||||
_("You are not assigned as a source user. Please contact your administrator.")
|
||||
)
|
||||
return redirect('/')
|
||||
|
||||
|
||||
# Get source
|
||||
source = source_user.source
|
||||
|
||||
|
||||
# Get complaints from this source (recent 5)
|
||||
from apps.complaints.models import Complaint
|
||||
complaints = Complaint.objects.filter(source=source).select_related(
|
||||
'patient', 'hospital', 'assigned_to'
|
||||
).order_by('-created_at')[:5]
|
||||
|
||||
|
||||
# Get inquiries from this source (recent 5)
|
||||
from apps.complaints.models import Inquiry
|
||||
inquiries = Inquiry.objects.filter(source=source).select_related(
|
||||
'patient', 'hospital', 'assigned_to'
|
||||
).order_by('-created_at')[:5]
|
||||
|
||||
|
||||
# Calculate statistics
|
||||
total_complaints = Complaint.objects.filter(source=source).count()
|
||||
total_inquiries = Inquiry.objects.filter(source=source).count()
|
||||
open_complaints = Complaint.objects.filter(source=source, status='open').count()
|
||||
open_inquiries = Inquiry.objects.filter(source=source, status='open').count()
|
||||
|
||||
|
||||
context = {
|
||||
'source_user': source_user,
|
||||
'source': source,
|
||||
@ -287,11 +310,12 @@ def source_user_dashboard(request):
|
||||
'can_create_complaints': source_user.can_create_complaints,
|
||||
'can_create_inquiries': source_user.can_create_inquiries,
|
||||
}
|
||||
|
||||
|
||||
return render(request, 'px_sources/source_user_dashboard.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
@source_user_required
|
||||
def ajax_source_choices(request):
|
||||
"""
|
||||
AJAX endpoint for getting source choices for dropdowns
|
||||
@ -311,27 +335,92 @@ def ajax_source_choices(request):
|
||||
|
||||
|
||||
@login_required
|
||||
@block_source_user
|
||||
def source_user_create(request, pk):
|
||||
"""
|
||||
Create a new source user for a specific PX source.
|
||||
Only PX admins can create source users.
|
||||
Allows selecting an existing user or creating a new user.
|
||||
"""
|
||||
# if not request.user.is_px_admin():
|
||||
# messages.error(request, _("You don't have permission to create source users."))
|
||||
# return redirect('px_sources:source_detail', pk=pk)
|
||||
|
||||
|
||||
source = get_object_or_404(PXSource, pk=pk)
|
||||
|
||||
|
||||
if request.method == 'POST':
|
||||
user_id = request.POST.get('user')
|
||||
user = get_object_or_404(User, pk=user_id)
|
||||
creation_mode = request.POST.get('creation_mode', 'existing') # 'existing' or 'new'
|
||||
|
||||
try:
|
||||
# Check if user already has a source user profile
|
||||
if SourceUser.objects.filter(user=user).exists():
|
||||
messages.error(request, _("User already has a source profile. A user can only manage one source."))
|
||||
return redirect('px_sources:source_detail', pk=pk)
|
||||
|
||||
if creation_mode == 'existing':
|
||||
# Select from existing users
|
||||
user_id = request.POST.get('user')
|
||||
if not user_id:
|
||||
messages.error(request, _("Please select a user."))
|
||||
return redirect('px_sources:source_user_create', pk=pk)
|
||||
|
||||
user = get_object_or_404(User, pk=user_id)
|
||||
|
||||
# Check if user already has a source user profile
|
||||
if SourceUser.objects.filter(user=user).exists():
|
||||
messages.error(request, _("User already has a source profile. A user can only manage one source."))
|
||||
return redirect('px_sources:source_detail', pk=pk)
|
||||
|
||||
else: # creation_mode == 'new'
|
||||
# Create a new user
|
||||
email = request.POST.get('new_email', '').strip().lower()
|
||||
first_name = request.POST.get('new_first_name', '').strip()
|
||||
last_name = request.POST.get('new_last_name', '').strip()
|
||||
password = request.POST.get('new_password', '')
|
||||
confirm_password = request.POST.get('new_password_confirm', '')
|
||||
|
||||
# Validation
|
||||
errors = []
|
||||
if not email:
|
||||
errors.append(_("Email is required."))
|
||||
elif User.objects.filter(email=email).exists():
|
||||
errors.append(_("A user with this email already exists."))
|
||||
|
||||
if not first_name:
|
||||
errors.append(_("First name is required."))
|
||||
|
||||
if not last_name:
|
||||
errors.append(_("Last name is required."))
|
||||
|
||||
if not password:
|
||||
errors.append(_("Password is required."))
|
||||
elif len(password) < 8:
|
||||
errors.append(_("Password must be at least 8 characters."))
|
||||
|
||||
if password != confirm_password:
|
||||
errors.append(_("Passwords do not match."))
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
messages.error(request, error)
|
||||
return redirect('px_sources:source_user_create', pk=pk)
|
||||
|
||||
# Create the user
|
||||
user = User.objects.create_user(
|
||||
email=email,
|
||||
password=password,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
phone=request.POST.get('new_phone', '').strip(),
|
||||
employee_id=request.POST.get('new_employee_id', '').strip(),
|
||||
)
|
||||
|
||||
# Assign to PX Admin group by default
|
||||
from django.contrib.auth.models import Group
|
||||
try:
|
||||
px_admin_group = Group.objects.get(name='PX Admin')
|
||||
user.groups.add(px_admin_group)
|
||||
except Group.DoesNotExist:
|
||||
pass # Group doesn't exist yet
|
||||
|
||||
messages.success(request, _("New user created successfully!"))
|
||||
|
||||
# Create source user
|
||||
source_user = SourceUser.objects.create(
|
||||
user=user,
|
||||
source=source,
|
||||
@ -339,24 +428,26 @@ def source_user_create(request, pk):
|
||||
can_create_complaints=request.POST.get('can_create_complaints') == 'on',
|
||||
can_create_inquiries=request.POST.get('can_create_inquiries') == 'on',
|
||||
)
|
||||
|
||||
|
||||
messages.success(request, _("Source user created successfully!"))
|
||||
return redirect('px_sources:source_detail', pk=pk)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, _("Error creating source user: {}").format(str(e)))
|
||||
|
||||
|
||||
context = {
|
||||
'source': source,
|
||||
'available_users': User.objects.exclude(
|
||||
id__in=source.source_users.values_list('user_id', flat=True)
|
||||
).order_by('email'),
|
||||
'creation_mode': 'new', # Default to new user creation
|
||||
}
|
||||
|
||||
|
||||
return render(request, 'px_sources/source_user_form.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
@block_source_user
|
||||
def source_user_edit(request, pk, user_pk):
|
||||
"""
|
||||
Edit an existing source user.
|
||||
@ -391,6 +482,7 @@ def source_user_edit(request, pk, user_pk):
|
||||
|
||||
|
||||
@login_required
|
||||
@block_source_user
|
||||
def source_user_delete(request, pk, user_pk):
|
||||
"""
|
||||
Delete a source user.
|
||||
@ -418,6 +510,7 @@ def source_user_delete(request, pk, user_pk):
|
||||
|
||||
|
||||
@login_required
|
||||
@block_source_user
|
||||
def source_user_toggle_status(request, pk, user_pk):
|
||||
"""
|
||||
Toggle source user active status (AJAX).
|
||||
@ -445,6 +538,7 @@ def source_user_toggle_status(request, pk, user_pk):
|
||||
|
||||
|
||||
@login_required
|
||||
@source_user_required
|
||||
def source_user_complaint_list(request):
|
||||
"""
|
||||
List complaints for the current Source User.
|
||||
@ -452,50 +546,49 @@ def source_user_complaint_list(request):
|
||||
"""
|
||||
# Get source user profile
|
||||
source_user = SourceUser.get_active_source_user(request.user)
|
||||
|
||||
|
||||
if not source_user:
|
||||
messages.error(
|
||||
request,
|
||||
request,
|
||||
_("You are not assigned as a source user. Please contact your administrator.")
|
||||
)
|
||||
return redirect('/')
|
||||
|
||||
|
||||
source = source_user.source
|
||||
|
||||
|
||||
# Get complaints from this source
|
||||
from apps.complaints.models import Complaint
|
||||
from apps.complaints.models import Complaint, Inquiry
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
complaints_queryset = Complaint.objects.filter(source=source).select_related(
|
||||
'patient', 'hospital', 'assigned_to', 'created_by'
|
||||
)
|
||||
|
||||
|
||||
# Apply filters
|
||||
status_filter = request.GET.get('status')
|
||||
if status_filter:
|
||||
complaints_queryset = complaints_queryset.filter(status=status_filter)
|
||||
|
||||
|
||||
priority_filter = request.GET.get('priority')
|
||||
if priority_filter:
|
||||
complaints_queryset = complaints_queryset.filter(priority=priority_filter)
|
||||
|
||||
|
||||
category_filter = request.GET.get('category')
|
||||
if category_filter:
|
||||
complaints_queryset = complaints_queryset.filter(category=category_filter)
|
||||
|
||||
|
||||
# Search
|
||||
search = request.GET.get('search')
|
||||
if search:
|
||||
complaints_queryset = complaints_queryset.filter(
|
||||
Q(title__icontains=search) |
|
||||
Q(description__icontains=search) |
|
||||
Q(patient__first_name__icontains=search) |
|
||||
Q(patient__last_name__icontains=search)
|
||||
Q(patient_name__icontains=search)
|
||||
)
|
||||
|
||||
|
||||
# Order and paginate
|
||||
complaints_queryset = complaints_queryset.order_by('-created_at')
|
||||
|
||||
|
||||
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
|
||||
paginator = Paginator(complaints_queryset, 20) # 20 per page
|
||||
page = request.GET.get('page')
|
||||
@ -505,7 +598,7 @@ def source_user_complaint_list(request):
|
||||
complaints = paginator.page(1)
|
||||
except EmptyPage:
|
||||
complaints = paginator.page(paginator.num_pages)
|
||||
|
||||
|
||||
context = {
|
||||
'complaints': complaints,
|
||||
'source_user': source_user,
|
||||
@ -515,12 +608,15 @@ def source_user_complaint_list(request):
|
||||
'category_filter': category_filter,
|
||||
'search': search,
|
||||
'complaints_count': complaints_queryset.count(),
|
||||
'total_complaints': Complaint.objects.filter(source=source).count(),
|
||||
'total_inquiries': Inquiry.objects.filter(source=source).count(),
|
||||
}
|
||||
|
||||
|
||||
return render(request, 'px_sources/source_user_complaint_list.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
@source_user_required
|
||||
def source_user_inquiry_list(request):
|
||||
"""
|
||||
List inquiries for the current Source User.
|
||||
@ -528,33 +624,33 @@ def source_user_inquiry_list(request):
|
||||
"""
|
||||
# Get source user profile
|
||||
source_user = SourceUser.get_active_source_user(request.user)
|
||||
|
||||
|
||||
if not source_user:
|
||||
messages.error(
|
||||
request,
|
||||
request,
|
||||
_("You are not assigned as a source user. Please contact your administrator.")
|
||||
)
|
||||
return redirect('/')
|
||||
|
||||
|
||||
source = source_user.source
|
||||
|
||||
|
||||
# Get inquiries from this source
|
||||
from apps.complaints.models import Inquiry
|
||||
from apps.complaints.models import Inquiry, Complaint
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
inquiries_queryset = Inquiry.objects.filter(source=source).select_related(
|
||||
'patient', 'hospital', 'assigned_to', 'created_by'
|
||||
)
|
||||
|
||||
|
||||
# Apply filters
|
||||
status_filter = request.GET.get('status')
|
||||
if status_filter:
|
||||
inquiries_queryset = inquiries_queryset.filter(status=status_filter)
|
||||
|
||||
|
||||
category_filter = request.GET.get('category')
|
||||
if category_filter:
|
||||
inquiries_queryset = inquiries_queryset.filter(category=category_filter)
|
||||
|
||||
|
||||
# Search
|
||||
search = request.GET.get('search')
|
||||
if search:
|
||||
@ -563,10 +659,10 @@ def source_user_inquiry_list(request):
|
||||
Q(message__icontains=search) |
|
||||
Q(contact_name__icontains=search)
|
||||
)
|
||||
|
||||
|
||||
# Order and paginate
|
||||
inquiries_queryset = inquiries_queryset.order_by('-created_at')
|
||||
|
||||
|
||||
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
|
||||
paginator = Paginator(inquiries_queryset, 20) # 20 per page
|
||||
page = request.GET.get('page')
|
||||
@ -576,7 +672,7 @@ def source_user_inquiry_list(request):
|
||||
inquiries = paginator.page(1)
|
||||
except EmptyPage:
|
||||
inquiries = paginator.page(paginator.num_pages)
|
||||
|
||||
|
||||
context = {
|
||||
'inquiries': inquiries,
|
||||
'source_user': source_user,
|
||||
@ -585,6 +681,291 @@ def source_user_inquiry_list(request):
|
||||
'category_filter': category_filter,
|
||||
'search': search,
|
||||
'inquiries_count': inquiries_queryset.count(),
|
||||
'total_complaints': Complaint.objects.filter(source=source).count(),
|
||||
'total_inquiries': Inquiry.objects.filter(source=source).count(),
|
||||
}
|
||||
|
||||
|
||||
return render(request, 'px_sources/source_user_inquiry_list.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
@source_user_required
|
||||
def source_user_create_complaint(request):
|
||||
"""
|
||||
Create a complaint for source users.
|
||||
|
||||
Simplified form that automatically:
|
||||
- Assigns the user's source
|
||||
- Sets the hospital from the source user's context
|
||||
- Hides admin-only fields
|
||||
"""
|
||||
from apps.complaints.forms import PublicComplaintForm
|
||||
from apps.complaints.models import Complaint
|
||||
from apps.complaints.tasks import notify_admins_new_complaint
|
||||
from apps.core.services import AuditService
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
source_user = SourceUser.get_active_source_user(request.user)
|
||||
|
||||
if not source_user or not source_user.can_create_complaints:
|
||||
messages.error(request, _("You don't have permission to create complaints."))
|
||||
return redirect('px_sources:source_user_dashboard')
|
||||
|
||||
source = source_user.source
|
||||
|
||||
if request.method == 'POST':
|
||||
form = PublicComplaintForm(request.POST, request.FILES)
|
||||
|
||||
# Add Tailwind CSS classes to form fields
|
||||
for field_name, field in form.fields.items():
|
||||
if field.widget.__class__.__name__ == 'TextInput':
|
||||
field.widget.attrs.update({'class': 'w-full px-4 py-3 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition'})
|
||||
elif field.widget.__class__.__name__ == 'Textarea':
|
||||
field.widget.attrs.update({'class': 'w-full px-4 py-3 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition', 'rows': '5'})
|
||||
elif field.widget.__class__.__name__ == 'Select':
|
||||
field.widget.attrs.update({'class': 'w-full px-4 py-3 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition bg-white'})
|
||||
elif field.widget.__class__.__name__ == 'DateInput':
|
||||
field.widget.attrs.update({'class': 'w-full px-4 py-3 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition', 'type': 'date'})
|
||||
elif field.widget.__class__.__name__ == 'EmailInput':
|
||||
field.widget.attrs.update({'class': 'w-full px-4 py-3 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition'})
|
||||
elif field.widget.__class__.__name__ == 'TelInput':
|
||||
field.widget.attrs.update({'class': 'w-full px-4 py-3 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition'})
|
||||
|
||||
# Auto-populate hospital and location if not provided
|
||||
if not form.data.get('hospital'):
|
||||
from apps.organizations.models import Hospital
|
||||
first_hospital = Hospital.objects.filter(status='active').first()
|
||||
if first_hospital:
|
||||
form.data = form.data.copy()
|
||||
form.data['hospital'] = str(first_hospital.id)
|
||||
|
||||
if not form.data.get('location'):
|
||||
from apps.organizations.models import Location
|
||||
first_location = Location.objects.first()
|
||||
if first_location:
|
||||
form.data = form.data.copy()
|
||||
form.data['location'] = str(first_location.id)
|
||||
|
||||
if form.is_valid():
|
||||
try:
|
||||
# Create complaint
|
||||
complaint = form.save(commit=False)
|
||||
|
||||
# Set source automatically
|
||||
complaint.source = source
|
||||
|
||||
# Set hospital (use source user's context)
|
||||
from apps.organizations.models import Hospital
|
||||
# Get the first active hospital or use default logic
|
||||
hospital = Hospital.objects.filter(status='active').first()
|
||||
if hospital:
|
||||
complaint.hospital = hospital
|
||||
|
||||
# Map complaint_details to description (form field vs model field)
|
||||
complaint.description = form.cleaned_data.get('complaint_details', '')
|
||||
|
||||
# Generate reference number
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
random_suffix = str(uuid.uuid4().int)[:6]
|
||||
complaint.reference_number = f"CMP-{today}-{random_suffix}"
|
||||
|
||||
# Set created by
|
||||
complaint.created_by = request.user
|
||||
|
||||
complaint.save()
|
||||
|
||||
# Create initial update
|
||||
from apps.complaints.models import ComplaintUpdate
|
||||
ComplaintUpdate.objects.create(
|
||||
complaint=complaint,
|
||||
update_type="note",
|
||||
message=f"Complaint submitted by {source.name_en} source user.",
|
||||
created_by=request.user,
|
||||
)
|
||||
|
||||
# Trigger AI analysis
|
||||
try:
|
||||
from apps.complaints.tasks import analyze_complaint_with_ai
|
||||
analyze_complaint_with_ai.delay(str(complaint.id))
|
||||
except:
|
||||
pass # AI analysis is optional
|
||||
|
||||
# Notify admins
|
||||
try:
|
||||
notify_admins_new_complaint.delay(str(complaint.id))
|
||||
except:
|
||||
pass # Notification is optional
|
||||
|
||||
# Log audit
|
||||
try:
|
||||
AuditService.log_event(
|
||||
event_type="complaint_created_by_source_user",
|
||||
description=f"Complaint created by source user: {complaint.reference_number}",
|
||||
user=request.user,
|
||||
content_object=complaint,
|
||||
metadata={
|
||||
'source': source.name_en,
|
||||
'source_user_id': str(source_user.id),
|
||||
},
|
||||
)
|
||||
except:
|
||||
pass # Audit logging is optional
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
f"Complaint submitted successfully! Reference: {complaint.reference_number}"
|
||||
)
|
||||
return redirect('px_sources:source_user_complaint_list')
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f"Error creating complaint: {str(e)}")
|
||||
else:
|
||||
messages.error(request, "Please correct the errors below.")
|
||||
else:
|
||||
form = PublicComplaintForm()
|
||||
|
||||
# Pre-populate hospital (get first active hospital)
|
||||
from apps.organizations.models import Hospital, Location
|
||||
first_hospital = Hospital.objects.filter(status='active').first()
|
||||
if first_hospital:
|
||||
form.initial['hospital'] = first_hospital.id
|
||||
|
||||
# Pre-populate location (get first location)
|
||||
first_location = Location.objects.first()
|
||||
if first_location:
|
||||
form.initial['location'] = first_location.id
|
||||
|
||||
# Add Tailwind CSS classes to form fields (for GET request too)
|
||||
for field_name, field in form.fields.items():
|
||||
if field.widget.__class__.__name__ == 'TextInput':
|
||||
field.widget.attrs.update({'class': 'w-full px-4 py-3 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition'})
|
||||
elif field.widget.__class__.__name__ == 'Textarea':
|
||||
field.widget.attrs.update({'class': 'w-full px-4 py-3 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition', 'rows': '5'})
|
||||
elif field.widget.__class__.__name__ == 'Select':
|
||||
field.widget.attrs.update({'class': 'w-full px-4 py-3 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition bg-white'})
|
||||
elif field.widget.__class__.__name__ == 'DateInput':
|
||||
field.widget.attrs.update({'class': 'w-full px-4 py-3 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition', 'type': 'date'})
|
||||
elif field.widget.__class__.__name__ == 'EmailInput':
|
||||
field.widget.attrs.update({'class': 'w-full px-4 py-3 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition'})
|
||||
elif field.widget.__class__.__name__ == 'TelInput':
|
||||
field.widget.attrs.update({'class': 'w-full px-4 py-3 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition'})
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'source': source,
|
||||
'source_user': source_user,
|
||||
}
|
||||
|
||||
return render(request, 'px_sources/source_user_create_complaint.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
@source_user_required
|
||||
def source_user_create_inquiry(request):
|
||||
"""
|
||||
Create an inquiry for source users.
|
||||
|
||||
Simplified form that automatically:
|
||||
- Assigns the user's source
|
||||
- Sets the hospital from the source user's context
|
||||
- Hides admin-only fields
|
||||
"""
|
||||
from apps.complaints.forms import PublicInquiryForm
|
||||
from apps.complaints.models import Inquiry
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
source_user = SourceUser.get_active_source_user(request.user)
|
||||
|
||||
if not source_user or not source_user.can_create_inquiries:
|
||||
messages.error(request, _("You don't have permission to create inquiries."))
|
||||
return redirect('px_sources:source_user_dashboard')
|
||||
|
||||
source = source_user.source
|
||||
|
||||
if request.method == 'POST':
|
||||
form = PublicInquiryForm(request.POST)
|
||||
|
||||
# Add Tailwind CSS classes to form fields
|
||||
for field_name, field in form.fields.items():
|
||||
if field.widget.__class__.__name__ == 'TextInput':
|
||||
field.widget.attrs.update({'class': 'w-full px-4 py-3 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition'})
|
||||
elif field.widget.__class__.__name__ == 'Textarea':
|
||||
field.widget.attrs.update({'class': 'w-full px-4 py-3 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition', 'rows': '5'})
|
||||
elif field.widget.__class__.__name__ == 'EmailInput':
|
||||
field.widget.attrs.update({'class': 'w-full px-4 py-3 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition'})
|
||||
elif field.widget.__class__.__name__ == 'TelInput':
|
||||
field.widget.attrs.update({'class': 'w-full px-4 py-3 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition'})
|
||||
|
||||
if form.is_valid():
|
||||
try:
|
||||
# Create inquiry
|
||||
inquiry = form.save(commit=False)
|
||||
|
||||
# Set source automatically
|
||||
inquiry.source = source
|
||||
|
||||
# Set hospital
|
||||
from apps.organizations.models import Hospital
|
||||
hospital = Hospital.objects.filter(status='active').first()
|
||||
if hospital:
|
||||
inquiry.hospital = hospital
|
||||
|
||||
# Generate reference number
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
random_suffix = str(uuid.uuid4().int)[:6]
|
||||
inquiry.reference_number = f"INQ-{today}-{random_suffix}"
|
||||
|
||||
# Set created by
|
||||
inquiry.created_by = request.user
|
||||
|
||||
inquiry.save()
|
||||
|
||||
# Log audit
|
||||
from apps.core.services import AuditService
|
||||
try:
|
||||
AuditService.log_event(
|
||||
event_type="inquiry_created_by_source_user",
|
||||
description=f"Inquiry created by source user: {inquiry.reference_number}",
|
||||
user=request.user,
|
||||
content_object=inquiry,
|
||||
metadata={
|
||||
'source': source.name_en,
|
||||
'source_user_id': str(source_user.id),
|
||||
},
|
||||
)
|
||||
except:
|
||||
pass # Audit logging is optional
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
f"Inquiry submitted successfully! Reference: {inquiry.reference_number}"
|
||||
)
|
||||
return redirect('px_sources:source_user_inquiry_list')
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f"Error creating inquiry: {str(e)}")
|
||||
else:
|
||||
messages.error(request, "Please correct the errors below.")
|
||||
else:
|
||||
form = PublicInquiryForm()
|
||||
|
||||
# Add Tailwind CSS classes to form fields (for GET request too)
|
||||
for field_name, field in form.fields.items():
|
||||
if field.widget.__class__.__name__ == 'TextInput':
|
||||
field.widget.attrs.update({'class': 'w-full px-4 py-3 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition'})
|
||||
elif field.widget.__class__.__name__ == 'Textarea':
|
||||
field.widget.attrs.update({'class': 'w-full px-4 py-3 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition', 'rows': '5'})
|
||||
elif field.widget.__class__.__name__ == 'EmailInput':
|
||||
field.widget.attrs.update({'class': 'w-full px-4 py-3 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition'})
|
||||
elif field.widget.__class__.__name__ == 'TelInput':
|
||||
field.widget.attrs.update({'class': 'w-full px-4 py-3 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition'})
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'source': source,
|
||||
'source_user': source_user,
|
||||
}
|
||||
|
||||
return render(request, 'px_sources/source_user_create_inquiry.html', context)
|
||||
|
||||
@ -15,7 +15,11 @@ urlpatterns = [
|
||||
path('complaints/', ui_views.source_user_complaint_list, name='source_user_complaint_list'),
|
||||
path('inquiries/', ui_views.source_user_inquiry_list, name='source_user_inquiry_list'),
|
||||
|
||||
# PX Sources Management Views
|
||||
# Source User Create (Simplified forms)
|
||||
path('complaints/new/', ui_views.source_user_create_complaint, name='source_user_create_complaint'),
|
||||
path('inquiries/new/', ui_views.source_user_create_inquiry, name='source_user_create_inquiry'),
|
||||
|
||||
# PX Sources Management Views (Admin only)
|
||||
path('<uuid:pk>/users/create/', ui_views.source_user_create, name='source_user_create'),
|
||||
path('<uuid:pk>/users/<uuid:user_pk>/edit/', ui_views.source_user_edit, name='source_user_edit'),
|
||||
path('<uuid:pk>/users/<uuid:user_pk>/delete/', ui_views.source_user_delete, name='source_user_delete'),
|
||||
|
||||
@ -15,24 +15,26 @@ from .serializers import (
|
||||
PXSourceListSerializer,
|
||||
PXSourceSerializer,
|
||||
)
|
||||
from .permissions import IsAdminUser, IsSourceUserOrAdmin, IsSourceUserOwnData
|
||||
|
||||
|
||||
class PXSourceViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for PX Sources with full CRUD operations.
|
||||
|
||||
|
||||
Permissions:
|
||||
- PX Admins: Full access to all sources
|
||||
- Hospital Admins: Can view and manage sources
|
||||
- Other users: Read-only access
|
||||
- Source Users: Read-only access to their own source
|
||||
- Other users: No access
|
||||
"""
|
||||
queryset = PXSource.objects.all()
|
||||
permission_classes = [IsAuthenticated]
|
||||
permission_classes = [IsAuthenticated, IsAdminUser]
|
||||
filterset_fields = ['is_active']
|
||||
search_fields = ['name_en', 'name_ar', 'description']
|
||||
ordering_fields = ['name_en', 'created_at']
|
||||
ordering = ['name_en']
|
||||
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Use different serializers based on action"""
|
||||
if self.action == 'list':
|
||||
@ -42,18 +44,28 @@ class PXSourceViewSet(viewsets.ModelViewSet):
|
||||
elif self.action == 'choices':
|
||||
return PXSourceChoiceSerializer
|
||||
return PXSourceSerializer
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter sources based on user role"""
|
||||
queryset = super().get_queryset()
|
||||
|
||||
|
||||
user = self.request.user
|
||||
|
||||
|
||||
# PX Admins see all sources
|
||||
if user.is_px_admin():
|
||||
return queryset
|
||||
|
||||
# All other authenticated users see active sources
|
||||
# Hospital Admins see all sources
|
||||
if user.is_hospital_admin():
|
||||
return queryset
|
||||
|
||||
# Source users see only their source (handled by IsSourceUserOrAdmin)
|
||||
if hasattr(user, 'source_user_profile'):
|
||||
source_user = user.source_user_profile
|
||||
if source_user.is_active:
|
||||
return queryset.filter(id=source_user.source.id)
|
||||
|
||||
# Default: active sources only
|
||||
return queryset.filter(is_active=True)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
|
||||
1
apps/reports/__init__.py
Normal file
1
apps/reports/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Reports App - Custom Report Builder
|
||||
0
apps/reports/migrations/__init__.py
Normal file
0
apps/reports/migrations/__init__.py
Normal file
363
apps/reports/models.py
Normal file
363
apps/reports/models.py
Normal file
@ -0,0 +1,363 @@
|
||||
"""
|
||||
Reports models - Custom Report Builder for PX360.
|
||||
|
||||
Supports creating, saving, and scheduling custom reports
|
||||
across multiple data sources (Complaints, Inquiries, Observations, etc.)
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from apps.core.models import TimeStampedModel, UUIDModel
|
||||
|
||||
|
||||
class DataSource(models.TextChoices):
|
||||
"""Available data sources for reports."""
|
||||
COMPLAINTS = 'complaints', 'Complaints'
|
||||
INQUIRIES = 'inquiries', 'Inquiries'
|
||||
OBSERVATIONS = 'observations', 'Observations'
|
||||
PX_ACTIONS = 'px_actions', 'PX Actions'
|
||||
SURVEYS = 'surveys', 'Surveys'
|
||||
PHYSICIANS = 'physicians', 'Physician Ratings'
|
||||
|
||||
|
||||
class ReportFrequency(models.TextChoices):
|
||||
"""Report schedule frequency options."""
|
||||
ONCE = 'once', 'One Time'
|
||||
DAILY = 'daily', 'Daily'
|
||||
WEEKLY = 'weekly', 'Weekly'
|
||||
MONTHLY = 'monthly', 'Monthly'
|
||||
QUARTERLY = 'quarterly', 'Quarterly'
|
||||
|
||||
|
||||
class ReportFormat(models.TextChoices):
|
||||
"""Export format options."""
|
||||
HTML = 'html', 'View Online'
|
||||
PDF = 'pdf', 'PDF'
|
||||
EXCEL = 'excel', 'Excel'
|
||||
CSV = 'csv', 'CSV'
|
||||
|
||||
|
||||
class ChartType(models.TextChoices):
|
||||
"""Chart type options."""
|
||||
BAR = 'bar', 'Bar Chart'
|
||||
LINE = 'line', 'Line Chart'
|
||||
PIE = 'pie', 'Pie Chart'
|
||||
DONUT = 'donut', 'Donut Chart'
|
||||
AREA = 'area', 'Area Chart'
|
||||
TABLE = 'table', 'Table Only'
|
||||
|
||||
|
||||
class SavedReport(UUIDModel, TimeStampedModel):
|
||||
"""
|
||||
Saved report configuration.
|
||||
|
||||
Stores all settings for a custom report that can be
|
||||
regenerated on demand or scheduled.
|
||||
"""
|
||||
name = models.CharField(max_length=200)
|
||||
description = models.TextField(blank=True)
|
||||
|
||||
# Data source
|
||||
data_source = models.CharField(
|
||||
max_length=50,
|
||||
choices=DataSource.choices,
|
||||
default=DataSource.COMPLAINTS
|
||||
)
|
||||
|
||||
# Filter configuration (JSON)
|
||||
# Example: {"date_range": "30d", "hospital": "uuid", "status": ["open", "in_progress"]}
|
||||
filter_config = models.JSONField(default=dict, blank=True)
|
||||
|
||||
# Column configuration (JSON)
|
||||
# Example: ["title", "status", "severity", "department__name", "created_at"]
|
||||
column_config = models.JSONField(default=list, blank=True)
|
||||
|
||||
# Grouping configuration (JSON)
|
||||
# Example: {"field": "department__name", "aggregation": "count"}
|
||||
grouping_config = models.JSONField(default=dict, blank=True)
|
||||
|
||||
# Sort configuration (JSON)
|
||||
# Example: [{"field": "created_at", "direction": "desc"}]
|
||||
sort_config = models.JSONField(default=list, blank=True)
|
||||
|
||||
# Chart configuration (JSON)
|
||||
# Example: {"type": "bar", "x_axis": "department__name", "y_axis": "count"}
|
||||
chart_config = models.JSONField(default=dict, blank=True)
|
||||
|
||||
# Owner and sharing
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='saved_reports'
|
||||
)
|
||||
is_shared = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Share with other users in the same hospital"
|
||||
)
|
||||
is_template = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Available as a template for others to use"
|
||||
)
|
||||
|
||||
# Organization scope
|
||||
hospital = models.ForeignKey(
|
||||
'organizations.Hospital',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='saved_reports',
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
|
||||
# Metadata
|
||||
last_run_at = models.DateTimeField(null=True, blank=True)
|
||||
last_run_count = models.IntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['data_source', '-created_at']),
|
||||
models.Index(fields=['created_by', '-created_at']),
|
||||
models.Index(fields=['hospital', 'is_shared']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class ReportSchedule(UUIDModel, TimeStampedModel):
|
||||
"""
|
||||
Schedule for automated report generation and delivery.
|
||||
"""
|
||||
report = models.ForeignKey(
|
||||
SavedReport,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='schedules'
|
||||
)
|
||||
|
||||
# Schedule settings
|
||||
frequency = models.CharField(
|
||||
max_length=20,
|
||||
choices=ReportFrequency.choices,
|
||||
default=ReportFrequency.WEEKLY
|
||||
)
|
||||
|
||||
# Delivery time (hour:minute in 24h format)
|
||||
# Stored as "HH:MM" string
|
||||
delivery_time = models.CharField(
|
||||
max_length=5,
|
||||
default='09:00',
|
||||
help_text="Time to send report (HH:MM format)"
|
||||
)
|
||||
|
||||
# Day of week for weekly reports (0=Monday, 6=Sunday)
|
||||
day_of_week = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="For weekly reports: 0=Monday, 6=Sunday"
|
||||
)
|
||||
|
||||
# Day of month for monthly reports (1-31)
|
||||
day_of_month = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="For monthly reports: 1-31"
|
||||
)
|
||||
|
||||
# Export format for scheduled reports
|
||||
export_format = models.CharField(
|
||||
max_length=20,
|
||||
choices=ReportFormat.choices,
|
||||
default=ReportFormat.PDF
|
||||
)
|
||||
|
||||
# Recipients
|
||||
recipients = models.JSONField(
|
||||
default=list,
|
||||
help_text="List of email addresses to receive the report"
|
||||
)
|
||||
|
||||
# Status
|
||||
is_active = models.BooleanField(default=True)
|
||||
last_run_at = models.DateTimeField(null=True, blank=True)
|
||||
next_run_at = models.DateTimeField(null=True, blank=True)
|
||||
last_error = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['next_run_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.report.name} - {self.get_frequency_display()}"
|
||||
|
||||
def calculate_next_run(self):
|
||||
"""Calculate the next run time based on frequency."""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
now = timezone.now()
|
||||
hour, minute = map(int, self.delivery_time.split(':'))
|
||||
|
||||
if self.frequency == ReportFrequency.DAILY:
|
||||
next_run = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
if next_run <= now:
|
||||
next_run += timedelta(days=1)
|
||||
return next_run
|
||||
|
||||
elif self.frequency == ReportFrequency.WEEKLY and self.day_of_week is not None:
|
||||
days_ahead = self.day_of_week - now.weekday()
|
||||
if days_ahead <= 0:
|
||||
days_ahead += 7
|
||||
next_run = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
next_run += timedelta(days=days_ahead)
|
||||
return next_run
|
||||
|
||||
elif self.frequency == ReportFrequency.MONTHLY and self.day_of_month:
|
||||
next_run = now.replace(day=min(self.day_of_month, 28), hour=hour, minute=minute, second=0, microsecond=0)
|
||||
if next_run <= now:
|
||||
if now.month == 12:
|
||||
next_run = next_run.replace(year=now.year + 1, month=1)
|
||||
else:
|
||||
next_run = next_run.replace(month=now.month + 1)
|
||||
return next_run
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class GeneratedReport(UUIDModel, TimeStampedModel):
|
||||
"""
|
||||
Record of a generated report instance.
|
||||
|
||||
Stores the actual data and metadata for each report run.
|
||||
"""
|
||||
saved_report = models.ForeignKey(
|
||||
SavedReport,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='generated_reports',
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
|
||||
# Report name (copied from saved report for standalone reports)
|
||||
name = models.CharField(max_length=200)
|
||||
data_source = models.CharField(max_length=50, choices=DataSource.choices)
|
||||
|
||||
# Configuration snapshots
|
||||
filter_config = models.JSONField(default=dict)
|
||||
column_config = models.JSONField(default=list)
|
||||
grouping_config = models.JSONField(default=dict)
|
||||
chart_config = models.JSONField(default=dict)
|
||||
|
||||
# Generated data
|
||||
data = models.JSONField(default=dict, help_text="The actual report data")
|
||||
summary = models.JSONField(default=dict, help_text="Summary statistics")
|
||||
chart_data = models.JSONField(default=dict, help_text="Chart-formatted data")
|
||||
|
||||
# Row count
|
||||
row_count = models.IntegerField(default=0)
|
||||
|
||||
# Generation metadata
|
||||
generated_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='generated_reports'
|
||||
)
|
||||
|
||||
# Export files
|
||||
pdf_file = models.FileField(upload_to='reports/pdf/', null=True, blank=True)
|
||||
excel_file = models.FileField(upload_to='reports/excel/', null=True, blank=True)
|
||||
csv_file = models.FileField(upload_to='reports/csv/', null=True, blank=True)
|
||||
|
||||
# Organization scope
|
||||
hospital = models.ForeignKey(
|
||||
'organizations.Hospital',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='generated_reports',
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
|
||||
# Expiry (auto-delete old reports)
|
||||
expires_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['data_source', '-created_at']),
|
||||
models.Index(fields=['generated_by', '-created_at']),
|
||||
models.Index(fields=['expires_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} - {self.created_at.strftime('%Y-%m-%d %H:%M')}"
|
||||
|
||||
def is_expired(self):
|
||||
"""Check if the report has expired."""
|
||||
if self.expires_at:
|
||||
return timezone.now() > self.expires_at
|
||||
return False
|
||||
|
||||
|
||||
class ReportTemplate(UUIDModel, TimeStampedModel):
|
||||
"""
|
||||
Pre-built report templates for quick access.
|
||||
|
||||
These are system-provided templates that users can use
|
||||
as starting points for their custom reports.
|
||||
"""
|
||||
name = models.CharField(max_length=200)
|
||||
description = models.TextField(blank=True)
|
||||
category = models.CharField(max_length=100, blank=True)
|
||||
|
||||
# Data source
|
||||
data_source = models.CharField(
|
||||
max_length=50,
|
||||
choices=DataSource.choices,
|
||||
default=DataSource.COMPLAINTS
|
||||
)
|
||||
|
||||
# Template configuration
|
||||
filter_config = models.JSONField(default=dict)
|
||||
column_config = models.JSONField(default=list)
|
||||
grouping_config = models.JSONField(default=dict)
|
||||
sort_config = models.JSONField(default=list)
|
||||
chart_config = models.JSONField(default=dict)
|
||||
|
||||
# Display
|
||||
icon = models.CharField(max_length=50, blank=True, help_text="Bootstrap icon class")
|
||||
sort_order = models.IntegerField(default=0)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['sort_order', 'name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def create_report(self, user, overrides=None):
|
||||
"""Create a SavedReport from this template."""
|
||||
from apps.reports.services import ReportBuilderService
|
||||
|
||||
config = {
|
||||
'filter_config': self.filter_config.copy(),
|
||||
'column_config': self.column_config.copy(),
|
||||
'grouping_config': self.grouping_config.copy(),
|
||||
'sort_config': self.sort_config.copy(),
|
||||
'chart_config': self.chart_config.copy(),
|
||||
}
|
||||
|
||||
if overrides:
|
||||
for key, value in overrides.items():
|
||||
if key in config and isinstance(config[key], dict):
|
||||
config[key].update(value)
|
||||
else:
|
||||
config[key] = value
|
||||
|
||||
report = SavedReport.objects.create(
|
||||
name=f"{self.name} - {timezone.now().strftime('%Y-%m-%d')}",
|
||||
description=self.description,
|
||||
data_source=self.data_source,
|
||||
created_by=user,
|
||||
hospital=user.hospital,
|
||||
**config
|
||||
)
|
||||
return report
|
||||
609
apps/reports/services.py
Normal file
609
apps/reports/services.py
Normal file
@ -0,0 +1,609 @@
|
||||
"""
|
||||
Report generation services for PX360 - Simplified Version
|
||||
|
||||
Handles data fetching, filtering, aggregation, and export
|
||||
for custom reports across all data sources. No chart functionality.
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime, timedelta
|
||||
from django.db.models import (
|
||||
Count, Sum, Avg, Min, Max, F, Q, Value,
|
||||
FloatField, IntegerField, CharField, ExpressionWrapper, DurationField
|
||||
)
|
||||
from django.db.models.functions import TruncDate, TruncMonth, TruncWeek, TruncYear, Extract
|
||||
from django.http import HttpResponse
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class ReportBuilderService:
|
||||
"""
|
||||
Service for building custom reports from various data sources.
|
||||
|
||||
Provides:
|
||||
- Data fetching with dynamic filters
|
||||
- Column selection
|
||||
- Grouping and aggregation
|
||||
- Summary statistics
|
||||
"""
|
||||
|
||||
# Available fields for each data source
|
||||
SOURCE_FIELDS = {
|
||||
'complaints': {
|
||||
'id': {'label': 'ID', 'field': 'id', 'type': 'string'},
|
||||
'reference_number': {'label': 'Reference Number', 'field': 'reference_number', 'type': 'string'},
|
||||
'title': {'label': 'Title', 'field': 'title', 'type': 'string'},
|
||||
'description': {'label': 'Description', 'field': 'description', 'type': 'text'},
|
||||
'status': {'label': 'Status', 'field': 'status', 'type': 'choice'},
|
||||
'severity': {'label': 'Severity', 'field': 'severity', 'type': 'choice'},
|
||||
'priority': {'label': 'Priority', 'field': 'priority', 'type': 'choice'},
|
||||
'source': {'label': 'Source', 'field': 'complaint_source_type', 'type': 'choice'},
|
||||
'hospital': {'label': 'Hospital', 'field': 'hospital__name', 'type': 'string'},
|
||||
'department': {'label': 'Department', 'field': 'department__name', 'type': 'string'},
|
||||
'section': {'label': 'Section', 'field': 'section__name', 'type': 'string'},
|
||||
'patient_name': {'label': 'Patient Name', 'field': 'patient__first_name', 'type': 'string'},
|
||||
'patient_mobile': {'label': 'Patient Mobile', 'field': 'patient__mobile_number', 'type': 'string'},
|
||||
'created_at': {'label': 'Created Date', 'field': 'created_at', 'type': 'datetime'},
|
||||
'updated_at': {'label': 'Updated Date', 'field': 'updated_at', 'type': 'datetime'},
|
||||
'due_at': {'label': 'Due Date', 'field': 'due_at', 'type': 'datetime'},
|
||||
'resolved_at': {'label': 'Resolved Date', 'field': 'resolved_at', 'type': 'datetime'},
|
||||
'is_overdue': {'label': 'Is Overdue', 'field': 'is_overdue', 'type': 'boolean'},
|
||||
'resolution_time_hours': {'label': 'Resolution Time (Hours)', 'field': 'resolution_time_hours', 'type': 'number'},
|
||||
'journey_type': {'label': 'Journey Type', 'field': 'journey__journey_type', 'type': 'string'},
|
||||
},
|
||||
'inquiries': {
|
||||
'id': {'label': 'ID', 'field': 'id', 'type': 'string'},
|
||||
'reference_number': {'label': 'Reference Number', 'field': 'reference_number', 'type': 'string'},
|
||||
'title': {'label': 'Title', 'field': 'title', 'type': 'string'},
|
||||
'description': {'label': 'Description', 'field': 'description', 'type': 'text'},
|
||||
'status': {'label': 'Status', 'field': 'status', 'type': 'choice'},
|
||||
'category': {'label': 'Category', 'field': 'category__name_en', 'type': 'string'},
|
||||
'hospital': {'label': 'Hospital', 'field': 'hospital__name', 'type': 'string'},
|
||||
'department': {'label': 'Department', 'field': 'department__name', 'type': 'string'},
|
||||
'created_at': {'label': 'Created Date', 'field': 'created_at', 'type': 'datetime'},
|
||||
'updated_at': {'label': 'Updated Date', 'field': 'updated_at', 'type': 'datetime'},
|
||||
},
|
||||
'observations': {
|
||||
'id': {'label': 'ID', 'field': 'id', 'type': 'string'},
|
||||
'tracking_code': {'label': 'Tracking Code', 'field': 'tracking_code', 'type': 'string'},
|
||||
'title': {'label': 'Title', 'field': 'title', 'type': 'string'},
|
||||
'description': {'label': 'Description', 'field': 'description', 'type': 'text'},
|
||||
'status': {'label': 'Status', 'field': 'status', 'type': 'choice'},
|
||||
'severity': {'label': 'Severity', 'field': 'severity', 'type': 'choice'},
|
||||
'category': {'label': 'Category', 'field': 'category__name_en', 'type': 'string'},
|
||||
'hospital': {'label': 'Hospital', 'field': 'hospital__name', 'type': 'string'},
|
||||
'department': {'label': 'Department', 'field': 'assigned_department__name', 'type': 'string'},
|
||||
'location': {'label': 'Location', 'field': 'location_text', 'type': 'string'},
|
||||
'created_at': {'label': 'Created Date', 'field': 'created_at', 'type': 'datetime'},
|
||||
'incident_datetime': {'label': 'Incident Date', 'field': 'incident_datetime', 'type': 'datetime'},
|
||||
},
|
||||
'px_actions': {
|
||||
'id': {'label': 'ID', 'field': 'id', 'type': 'string'},
|
||||
'title': {'label': 'Title', 'field': 'title', 'type': 'string'},
|
||||
'description': {'label': 'Description', 'field': 'description', 'type': 'text'},
|
||||
'status': {'label': 'Status', 'field': 'status', 'type': 'choice'},
|
||||
'priority': {'label': 'Priority', 'field': 'priority', 'type': 'choice'},
|
||||
'action_type': {'label': 'Action Type', 'field': 'action_type', 'type': 'choice'},
|
||||
'hospital': {'label': 'Hospital', 'field': 'hospital__name', 'type': 'string'},
|
||||
'department': {'label': 'Department', 'field': 'department__name', 'type': 'string'},
|
||||
'created_at': {'label': 'Created Date', 'field': 'created_at', 'type': 'datetime'},
|
||||
'due_at': {'label': 'Due Date', 'field': 'due_at', 'type': 'datetime'},
|
||||
'completed_at': {'label': 'Completed Date', 'field': 'completed_at', 'type': 'datetime'},
|
||||
'is_overdue': {'label': 'Is Overdue', 'field': 'is_overdue', 'type': 'boolean'},
|
||||
},
|
||||
'surveys': {
|
||||
'id': {'label': 'ID', 'field': 'id', 'type': 'string'},
|
||||
'survey_template': {'label': 'Survey Template', 'field': 'survey_template__name', 'type': 'string'},
|
||||
'status': {'label': 'Status', 'field': 'status', 'type': 'choice'},
|
||||
'total_score': {'label': 'Total Score', 'field': 'total_score', 'type': 'number'},
|
||||
'is_negative': {'label': 'Is Negative', 'field': 'is_negative', 'type': 'boolean'},
|
||||
'patient_type': {'label': 'Patient Type', 'field': 'journey__patient_type', 'type': 'string'},
|
||||
'journey_type': {'label': 'Journey Type', 'field': 'journey__journey_type', 'type': 'string'},
|
||||
'hospital': {'label': 'Hospital', 'field': 'survey_template__hospital__name', 'type': 'string'},
|
||||
'department': {'label': 'Department', 'field': 'journey__department__name', 'type': 'string'},
|
||||
'created_at': {'label': 'Created Date', 'field': 'created_at', 'type': 'datetime'},
|
||||
'completed_at': {'label': 'Completed Date', 'field': 'completed_at', 'type': 'datetime'},
|
||||
},
|
||||
'physicians': {
|
||||
'id': {'label': 'ID', 'field': 'id', 'type': 'string'},
|
||||
'physician_name': {'label': 'Physician Name', 'field': 'physician__full_name', 'type': 'string'},
|
||||
'department': {'label': 'Department', 'field': 'department__name', 'type': 'string'},
|
||||
'month': {'label': 'Month', 'field': 'month', 'type': 'string'},
|
||||
'year': {'label': 'Year', 'field': 'year', 'type': 'number'},
|
||||
'total_surveys': {'label': 'Total Surveys', 'field': 'total_surveys', 'type': 'number'},
|
||||
'avg_rating': {'label': 'Average Rating', 'field': 'avg_rating', 'type': 'number'},
|
||||
'positive_count': {'label': 'Positive', 'field': 'positive_count', 'type': 'number'},
|
||||
'neutral_count': {'label': 'Neutral', 'field': 'neutral_count', 'type': 'number'},
|
||||
'negative_count': {'label': 'Negative', 'field': 'negative_count', 'type': 'number'},
|
||||
},
|
||||
}
|
||||
|
||||
# Filter options for each data source
|
||||
SOURCE_FILTERS = {
|
||||
'complaints': [
|
||||
{'name': 'date_range', 'label': 'Date Range', 'type': 'daterange'},
|
||||
{'name': 'status', 'label': 'Status', 'type': 'multiselect'},
|
||||
{'name': 'severity', 'label': 'Severity', 'type': 'multiselect'},
|
||||
{'name': 'priority', 'label': 'Priority', 'type': 'multiselect'},
|
||||
{'name': 'hospital', 'label': 'Hospital', 'type': 'select'},
|
||||
{'name': 'department', 'label': 'Department', 'type': 'select'},
|
||||
{'name': 'section', 'label': 'Section', 'type': 'select'},
|
||||
{'name': 'source', 'label': 'Source', 'type': 'multiselect'},
|
||||
{'name': 'is_overdue', 'label': 'Is Overdue', 'type': 'boolean'},
|
||||
{'name': 'journey_type', 'label': 'Journey Type', 'type': 'select'},
|
||||
],
|
||||
'inquiries': [
|
||||
{'name': 'date_range', 'label': 'Date Range', 'type': 'daterange'},
|
||||
{'name': 'status', 'label': 'Status', 'type': 'multiselect'},
|
||||
{'name': 'hospital', 'label': 'Hospital', 'type': 'select'},
|
||||
{'name': 'department', 'label': 'Department', 'type': 'select'},
|
||||
{'name': 'category', 'label': 'Category', 'type': 'select'},
|
||||
],
|
||||
'observations': [
|
||||
{'name': 'date_range', 'label': 'Date Range', 'type': 'daterange'},
|
||||
{'name': 'status', 'label': 'Status', 'type': 'multiselect'},
|
||||
{'name': 'severity', 'label': 'Severity', 'type': 'multiselect'},
|
||||
{'name': 'hospital', 'label': 'Hospital', 'type': 'select'},
|
||||
{'name': 'department', 'label': 'Department', 'type': 'select'},
|
||||
{'name': 'category', 'label': 'Category', 'type': 'select'},
|
||||
],
|
||||
'px_actions': [
|
||||
{'name': 'date_range', 'label': 'Date Range', 'type': 'daterange'},
|
||||
{'name': 'status', 'label': 'Status', 'type': 'multiselect'},
|
||||
{'name': 'priority', 'label': 'Priority', 'type': 'multiselect'},
|
||||
{'name': 'action_type', 'label': 'Action Type', 'type': 'multiselect'},
|
||||
{'name': 'hospital', 'label': 'Hospital', 'type': 'select'},
|
||||
{'name': 'department', 'label': 'Department', 'type': 'select'},
|
||||
{'name': 'is_overdue', 'label': 'Is Overdue', 'type': 'boolean'},
|
||||
],
|
||||
'surveys': [
|
||||
{'name': 'date_range', 'label': 'Date Range', 'type': 'daterange'},
|
||||
{'name': 'status', 'label': 'Status', 'type': 'multiselect'},
|
||||
{'name': 'is_negative', 'label': 'Is Negative', 'type': 'boolean'},
|
||||
{'name': 'hospital', 'label': 'Hospital', 'type': 'select'},
|
||||
{'name': 'department', 'label': 'Department', 'type': 'select'},
|
||||
{'name': 'patient_type', 'label': 'Patient Type', 'type': 'select'},
|
||||
{'name': 'journey_type', 'label': 'Journey Type', 'type': 'select'},
|
||||
],
|
||||
'physicians': [
|
||||
{'name': 'month_range', 'label': 'Month Range', 'type': 'monthrange'},
|
||||
{'name': 'hospital', 'label': 'Hospital', 'type': 'select'},
|
||||
{'name': 'department', 'label': 'Department', 'type': 'select'},
|
||||
],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_queryset(cls, data_source):
|
||||
"""Get the base queryset for a data source."""
|
||||
from apps.complaints.models import Complaint
|
||||
from apps.observations.models import Observation
|
||||
from apps.px_action_center.models import PXAction
|
||||
from apps.surveys.models import SurveyInstance
|
||||
from apps.physicians.models import PhysicianMonthlyRating
|
||||
|
||||
querysets = {
|
||||
'complaints': Complaint.objects.all(),
|
||||
'inquiries': Complaint.objects.filter(complaint_type='inquiry'),
|
||||
'observations': Observation.objects.all(),
|
||||
'px_actions': PXAction.objects.all(),
|
||||
'surveys': SurveyInstance.objects.all(),
|
||||
'physicians': PhysicianMonthlyRating.objects.all(),
|
||||
}
|
||||
|
||||
return querysets.get(data_source)
|
||||
|
||||
@classmethod
|
||||
def apply_filters(cls, queryset, filters, data_source):
|
||||
"""Apply filters to a queryset."""
|
||||
# Date range filter
|
||||
if 'date_range' in filters:
|
||||
date_range = filters['date_range']
|
||||
date_field = 'created_at'
|
||||
|
||||
if date_range == '7d':
|
||||
start_date = timezone.now() - timedelta(days=7)
|
||||
elif date_range == '30d':
|
||||
start_date = timezone.now() - timedelta(days=30)
|
||||
elif date_range == '90d':
|
||||
start_date = timezone.now() - timedelta(days=90)
|
||||
elif date_range == 'ytd':
|
||||
start_date = timezone.now().replace(month=1, day=1)
|
||||
elif date_range == 'custom' and 'start_date' in filters and 'end_date' in filters:
|
||||
start_date = filters['start_date']
|
||||
end_date = filters['end_date']
|
||||
queryset = queryset.filter(**{f'{date_field}__gte': start_date, f'{date_field}__lte': end_date})
|
||||
return queryset
|
||||
else:
|
||||
start_date = timezone.now() - timedelta(days=30)
|
||||
|
||||
queryset = queryset.filter(**{f'{date_field}__gte': start_date})
|
||||
|
||||
# Hospital filter
|
||||
if 'hospital' in filters and filters['hospital']:
|
||||
queryset = queryset.filter(hospital_id=filters['hospital'])
|
||||
|
||||
# Department filter
|
||||
if 'department' in filters and filters['department']:
|
||||
if data_source == 'observations':
|
||||
queryset = queryset.filter(assigned_department_id=filters['department'])
|
||||
elif data_source == 'surveys':
|
||||
queryset = queryset.filter(journey__department_id=filters['department'])
|
||||
else:
|
||||
queryset = queryset.filter(department_id=filters['department'])
|
||||
|
||||
# Section filter
|
||||
if 'section' in filters and filters['section']:
|
||||
queryset = queryset.filter(section_id=filters['section'])
|
||||
|
||||
# Status filter
|
||||
if 'status' in filters and filters['status']:
|
||||
if isinstance(filters['status'], list):
|
||||
queryset = queryset.filter(status__in=filters['status'])
|
||||
else:
|
||||
queryset = queryset.filter(status=filters['status'])
|
||||
|
||||
# Severity filter
|
||||
if 'severity' in filters and filters['severity']:
|
||||
if isinstance(filters['severity'], list):
|
||||
queryset = queryset.filter(severity__in=filters['severity'])
|
||||
else:
|
||||
queryset = queryset.filter(severity=filters['severity'])
|
||||
|
||||
# Priority filter
|
||||
if 'priority' in filters and filters['priority']:
|
||||
if isinstance(filters['priority'], list):
|
||||
queryset = queryset.filter(priority__in=filters['priority'])
|
||||
else:
|
||||
queryset = queryset.filter(priority=filters['priority'])
|
||||
|
||||
# Source filter (for complaints)
|
||||
if 'source' in filters and filters['source']:
|
||||
if isinstance(filters['source'], list):
|
||||
queryset = queryset.filter(complaint_source_type__in=filters['source'])
|
||||
else:
|
||||
queryset = queryset.filter(complaint_source_type=filters['source'])
|
||||
|
||||
# Is overdue filter
|
||||
if 'is_overdue' in filters:
|
||||
if filters['is_overdue'] == 'true' or filters['is_overdue'] is True:
|
||||
queryset = queryset.filter(is_overdue=True)
|
||||
elif filters['is_overdue'] == 'false' or filters['is_overdue'] is False:
|
||||
queryset = queryset.filter(is_overdue=False)
|
||||
|
||||
# Is negative filter (for surveys)
|
||||
if 'is_negative' in filters:
|
||||
if filters['is_negative'] == 'true' or filters['is_negative'] is True:
|
||||
queryset = queryset.filter(is_negative=True)
|
||||
elif filters['is_negative'] == 'false' or filters['is_negative'] is False:
|
||||
queryset = queryset.filter(is_negative=False)
|
||||
|
||||
# Journey type filter
|
||||
if 'journey_type' in filters and filters['journey_type']:
|
||||
if data_source == 'complaints':
|
||||
queryset = queryset.filter(journey__journey_type=filters['journey_type'])
|
||||
elif data_source == 'surveys':
|
||||
queryset = queryset.filter(journey__journey_type=filters['journey_type'])
|
||||
|
||||
# Patient type filter
|
||||
if 'patient_type' in filters and filters['patient_type']:
|
||||
queryset = queryset.filter(journey__patient_type=filters['patient_type'])
|
||||
|
||||
return queryset
|
||||
|
||||
@classmethod
|
||||
def apply_grouping(cls, queryset, grouping_config, data_source):
|
||||
"""Apply grouping and aggregation to a queryset."""
|
||||
if not grouping_config or 'field' not in grouping_config:
|
||||
return queryset
|
||||
|
||||
field = grouping_config['field']
|
||||
aggregation = grouping_config.get('aggregation', 'count')
|
||||
|
||||
# Determine truncation for date fields
|
||||
if 'created_at' in field or 'date' in field.lower():
|
||||
trunc_by = grouping_config.get('trunc_by', 'day')
|
||||
if trunc_by == 'day':
|
||||
queryset = queryset.annotate(period=TruncDate(field))
|
||||
elif trunc_by == 'week':
|
||||
queryset = queryset.annotate(period=TruncWeek(field))
|
||||
elif trunc_by == 'month':
|
||||
queryset = queryset.annotate(period=TruncMonth(field))
|
||||
elif trunc_by == 'year':
|
||||
queryset = queryset.annotate(period=TruncYear(field))
|
||||
field = 'period'
|
||||
|
||||
# Apply aggregation
|
||||
if aggregation == 'count':
|
||||
return queryset.values(field).annotate(count=Count('id')).order_by(field)
|
||||
elif aggregation == 'sum':
|
||||
sum_field = grouping_config.get('sum_field', 'id')
|
||||
return queryset.values(field).annotate(total=Sum(sum_field)).order_by(field)
|
||||
elif aggregation == 'avg':
|
||||
avg_field = grouping_config.get('avg_field', 'total_score')
|
||||
return queryset.values(field).annotate(average=Avg(avg_field)).order_by(field)
|
||||
|
||||
return queryset
|
||||
|
||||
@classmethod
|
||||
def get_field_value(cls, obj, field_path):
|
||||
"""Get a value from an object using dot notation."""
|
||||
parts = field_path.split('__')
|
||||
value = obj
|
||||
for part in parts:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, dict):
|
||||
value = value.get(part)
|
||||
else:
|
||||
value = getattr(value, part, None)
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def format_value(cls, value, field_type):
|
||||
"""Format a value for display."""
|
||||
if value is None:
|
||||
return ''
|
||||
|
||||
if field_type == 'datetime':
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return value.strftime('%Y-%m-%d %H:%M')
|
||||
elif field_type == 'date':
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return value.strftime('%Y-%m-%d')
|
||||
elif field_type == 'boolean':
|
||||
return 'Yes' if value else 'No'
|
||||
elif field_type == 'number':
|
||||
if isinstance(value, (int, float)):
|
||||
return round(value, 2) if isinstance(value, float) else value
|
||||
return value
|
||||
|
||||
return str(value) if value else ''
|
||||
|
||||
@classmethod
|
||||
def generate_report_data(cls, data_source, filter_config, column_config, grouping_config, sort_config=None):
|
||||
"""Generate report data with filters, columns, and grouping."""
|
||||
queryset = cls.get_queryset(data_source)
|
||||
queryset = cls.apply_filters(queryset, filter_config, data_source)
|
||||
|
||||
# Determine columns to select
|
||||
if not column_config:
|
||||
column_config = list(cls.SOURCE_FIELDS.get(data_source, {}).keys())[:10]
|
||||
|
||||
fields_info = cls.SOURCE_FIELDS.get(data_source, {})
|
||||
|
||||
if grouping_config and 'field' in grouping_config:
|
||||
# Grouped data
|
||||
grouped_data = cls.apply_grouping(queryset, grouping_config, data_source)
|
||||
|
||||
rows = []
|
||||
for item in grouped_data:
|
||||
row = {}
|
||||
for key, value in item.items():
|
||||
row[key] = cls.format_value(value, 'number' if key == 'count' else 'string')
|
||||
rows.append(row)
|
||||
|
||||
return {
|
||||
'rows': rows,
|
||||
'columns': list(grouped_data[0].keys()) if grouped_data else ['field', 'count'],
|
||||
'grouped': True,
|
||||
}
|
||||
else:
|
||||
# Regular data
|
||||
select_fields = []
|
||||
for col in column_config:
|
||||
if col in fields_info:
|
||||
select_fields.append(fields_info[col]['field'])
|
||||
|
||||
# Apply sorting
|
||||
if sort_config:
|
||||
for sort_item in sort_config:
|
||||
field = sort_item.get('field')
|
||||
direction = sort_item.get('direction', 'asc')
|
||||
if field in fields_info:
|
||||
order_field = fields_info[field]['field']
|
||||
if direction == 'desc':
|
||||
order_field = f'-{order_field}'
|
||||
queryset = queryset.order_by(order_field)
|
||||
|
||||
# Limit results for performance
|
||||
queryset = queryset[:1000]
|
||||
|
||||
rows = []
|
||||
for obj in queryset:
|
||||
row = {}
|
||||
for col in column_config:
|
||||
if col in fields_info:
|
||||
field_info = fields_info[col]
|
||||
value = cls.get_field_value(obj, field_info['field'])
|
||||
row[col] = cls.format_value(value, field_info['type'])
|
||||
rows.append(row)
|
||||
|
||||
# Return both keys (for data access) and labels (for display)
|
||||
column_labels = [fields_info.get(col, {'label': col})['label'] for col in column_config]
|
||||
|
||||
return {
|
||||
'rows': rows,
|
||||
'columns': column_labels,
|
||||
'column_keys': column_config, # Add field keys for data access
|
||||
'grouped': False,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def generate_summary(cls, data_source, filter_config):
|
||||
"""Generate summary statistics for a data source."""
|
||||
queryset = cls.get_queryset(data_source)
|
||||
queryset = cls.apply_filters(queryset, filter_config, data_source)
|
||||
|
||||
summary = {
|
||||
'total_count': queryset.count(),
|
||||
}
|
||||
|
||||
if data_source == 'complaints':
|
||||
summary['open_count'] = queryset.filter(status='open').count()
|
||||
summary['resolved_count'] = queryset.filter(status='resolved').count()
|
||||
summary['overdue_count'] = queryset.filter(is_overdue=True).count()
|
||||
# Calculate average resolution time in hours (SQLite-compatible)
|
||||
resolved_complaints = queryset.filter(resolved_at__isnull=False)
|
||||
if resolved_complaints.exists():
|
||||
# Calculate in Python to avoid SQLite DurationField limitation
|
||||
total_hours = 0
|
||||
count = 0
|
||||
for complaint in resolved_complaints.values('created_at', 'resolved_at'):
|
||||
if complaint['created_at'] and complaint['resolved_at']:
|
||||
delta = complaint['resolved_at'] - complaint['created_at']
|
||||
total_hours += delta.total_seconds() / 3600.0
|
||||
count += 1
|
||||
summary['avg_resolution_time'] = round(total_hours / count, 2) if count > 0 else 0
|
||||
else:
|
||||
summary['avg_resolution_time'] = 0
|
||||
|
||||
elif data_source == 'surveys':
|
||||
summary['completed_count'] = queryset.filter(status='completed').count()
|
||||
summary['pending_count'] = queryset.filter(status='pending').count()
|
||||
summary['negative_count'] = queryset.filter(is_negative=True).count()
|
||||
summary['avg_score'] = queryset.filter(
|
||||
status='completed'
|
||||
).aggregate(avg=Avg('total_score'))['avg'] or 0
|
||||
|
||||
elif data_source == 'px_actions':
|
||||
summary['open_count'] = queryset.filter(status='open').count()
|
||||
summary['completed_count'] = queryset.filter(status='completed').count()
|
||||
summary['overdue_count'] = queryset.filter(is_overdue=True).count()
|
||||
|
||||
elif data_source == 'observations':
|
||||
summary['new_count'] = queryset.filter(status='new').count()
|
||||
summary['resolved_count'] = queryset.filter(status='resolved').count()
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
class ReportExportService:
|
||||
"""Service for exporting reports to various formats."""
|
||||
|
||||
@classmethod
|
||||
def export_to_csv(cls, data, columns, column_keys=None, filename='report'):
|
||||
"""Export report data to CSV.
|
||||
|
||||
Args:
|
||||
data: List of row dicts
|
||||
columns: List of column labels (for header row)
|
||||
column_keys: List of column keys (for data access). If None, uses columns.
|
||||
filename: Output filename without extension
|
||||
"""
|
||||
response = HttpResponse(content_type='text/csv')
|
||||
response['Content-Disposition'] = f'attachment; filename="{filename}.csv"'
|
||||
|
||||
writer = csv.writer(response)
|
||||
writer.writerow(columns) # Write header row with labels
|
||||
|
||||
# Use column_keys for data access if provided, otherwise use columns
|
||||
keys = column_keys if column_keys else columns
|
||||
|
||||
for row in data:
|
||||
writer.writerow([row.get(key, '') for key in keys])
|
||||
|
||||
return response
|
||||
|
||||
@classmethod
|
||||
def export_to_excel(cls, data, columns, column_keys=None, filename='report'):
|
||||
"""Export report data to Excel (XLSX).
|
||||
|
||||
Args:
|
||||
data: List of row dicts
|
||||
columns: List of column labels (for header row)
|
||||
column_keys: List of column keys (for data access). If None, uses columns.
|
||||
filename: Output filename without extension
|
||||
"""
|
||||
try:
|
||||
import openpyxl
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
from openpyxl.utils import get_column_letter
|
||||
except ImportError:
|
||||
# Fall back to CSV if openpyxl not available
|
||||
return cls.export_to_csv(data, columns, column_keys, filename)
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = 'Report'
|
||||
|
||||
# Header row
|
||||
header_fill = PatternFill(start_color='1F4E79', end_color='1F4E79', fill_type='solid')
|
||||
header_font = Font(bold=True, color='FFFFFF')
|
||||
|
||||
for col_idx, col_name in enumerate(columns, 1):
|
||||
cell = ws.cell(row=1, column=col_idx, value=col_name)
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.alignment = Alignment(horizontal='center')
|
||||
|
||||
# Use column_keys for data access if provided, otherwise use columns
|
||||
keys = column_keys if column_keys else columns
|
||||
|
||||
# Data rows
|
||||
for row_idx, row_data in enumerate(data, 2):
|
||||
for col_idx, key in enumerate(keys, 1):
|
||||
value = row_data.get(key, '')
|
||||
ws.cell(row=row_idx, column=col_idx, value=str(value) if value else '')
|
||||
|
||||
# Auto-adjust column widths
|
||||
for col_idx, col_name in enumerate(columns, 1):
|
||||
max_length = len(str(col_name))
|
||||
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, min_col=col_idx, max_col=col_idx):
|
||||
for cell in row:
|
||||
if cell.value:
|
||||
max_length = max(max_length, len(str(cell.value)))
|
||||
ws.column_dimensions[get_column_letter(col_idx)].width = min(max_length + 2, 50)
|
||||
|
||||
# Create response
|
||||
response = HttpResponse(
|
||||
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
)
|
||||
response['Content-Disposition'] = f'attachment; filename="{filename}.xlsx"'
|
||||
|
||||
wb.save(response)
|
||||
return response
|
||||
|
||||
@classmethod
|
||||
def export_to_pdf(cls, data, columns, column_keys=None, title='Report', filename='report'):
|
||||
"""Export report data to PDF.
|
||||
|
||||
Args:
|
||||
data: List of row dicts
|
||||
columns: List of column labels (for header row)
|
||||
column_keys: List of column keys (for data access). If None, uses columns.
|
||||
title: Report title
|
||||
filename: Output filename without extension
|
||||
"""
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
# Use column_keys for data access if provided, otherwise use columns
|
||||
keys = column_keys if column_keys else columns
|
||||
|
||||
# Prepare data with proper column access
|
||||
formatted_data = []
|
||||
for row in data:
|
||||
formatted_row = {col: row.get(key, '') for col, key in zip(columns, keys)}
|
||||
formatted_data.append(formatted_row)
|
||||
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
|
||||
html_content = render_to_string('reports/report_pdf.html', {
|
||||
'title': title,
|
||||
'columns': columns,
|
||||
'data': formatted_data,
|
||||
'generated_at': timezone.now(),
|
||||
})
|
||||
|
||||
response = HttpResponse(content_type='application/pdf')
|
||||
response['Content-Disposition'] = f'attachment; filename="{filename}.pdf"'
|
||||
|
||||
HTML(string=html_content).write_pdf(response)
|
||||
return response
|
||||
|
||||
except ImportError:
|
||||
# Fall back to CSV if weasyprint not available
|
||||
return cls.export_to_csv(data, columns, column_keys, filename)
|
||||
28
apps/reports/urls.py
Normal file
28
apps/reports/urls.py
Normal file
@ -0,0 +1,28 @@
|
||||
"""
|
||||
Reports App URL Configuration
|
||||
"""
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'reports'
|
||||
|
||||
urlpatterns = [
|
||||
# Report Builder
|
||||
path('', views.report_builder, name='builder'),
|
||||
path('preview/', views.report_preview_api, name='preview_api'),
|
||||
path('save/', views.save_report, name='save_report'),
|
||||
|
||||
# Saved Reports
|
||||
path('saved/', views.saved_reports_list, name='saved_reports'),
|
||||
path('saved/<uuid:report_id>/', views.report_detail, name='report_detail'),
|
||||
path('saved/<uuid:report_id>/delete/', views.delete_report, name='delete_report'),
|
||||
path('saved/<uuid:report_id>/export/<str:export_format>/', views.export_report, name='export_report'),
|
||||
|
||||
# Templates
|
||||
path('templates/', views.report_templates, name='templates'),
|
||||
path('templates/<uuid:template_id>/use/', views.use_template, name='use_template'),
|
||||
|
||||
# API Endpoints
|
||||
path('api/filter-options/', views.filter_options_api, name='filter_options_api'),
|
||||
path('api/fields/', views.available_fields_api, name='available_fields_api'),
|
||||
]
|
||||
437
apps/reports/views.py
Normal file
437
apps/reports/views.py
Normal file
@ -0,0 +1,437 @@
|
||||
"""
|
||||
Report Builder UI Views - Simplified Version
|
||||
|
||||
Handles the visual report builder interface, saved reports,
|
||||
and exports. No chart functionality.
|
||||
"""
|
||||
import json
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib import messages
|
||||
from django.http import JsonResponse, HttpResponse
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from django.views.decorators.http import require_POST, require_GET
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
from django.utils import timezone
|
||||
from django.core.paginator import Paginator
|
||||
|
||||
from apps.organizations.models import Department, Hospital
|
||||
from .models import (
|
||||
SavedReport, GeneratedReport, ReportTemplate,
|
||||
DataSource, ReportFormat
|
||||
)
|
||||
from .services import ReportBuilderService, ReportExportService
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def report_builder(request):
|
||||
"""
|
||||
Visual report builder interface.
|
||||
|
||||
Allows creating custom reports with:
|
||||
- Data source selection
|
||||
- Dynamic filters
|
||||
- Column selection
|
||||
- Chart configuration
|
||||
"""
|
||||
user = request.user
|
||||
|
||||
# Get hospitals for filter
|
||||
hospitals = Hospital.objects.filter(status='active')
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
hospitals = hospitals.filter(id=user.hospital.id)
|
||||
|
||||
# Get saved reports
|
||||
saved_reports = SavedReport.objects.filter(
|
||||
created_by=user
|
||||
).order_by('-created_at')[:10]
|
||||
|
||||
context = {
|
||||
'hospitals': hospitals,
|
||||
'saved_reports': saved_reports,
|
||||
'data_sources': DataSource.choices,
|
||||
}
|
||||
|
||||
return render(request, 'reports/report_builder.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def report_preview_api(request):
|
||||
"""
|
||||
API endpoint to preview report data.
|
||||
|
||||
Returns JSON with:
|
||||
- Report data rows
|
||||
- Summary statistics
|
||||
- Chart data
|
||||
"""
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'error': 'POST required'}, status=405)
|
||||
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({'error': 'Invalid JSON'}, status=400)
|
||||
|
||||
data_source = data.get('data_source', 'complaints')
|
||||
filter_config = data.get('filter_config', {})
|
||||
column_config = data.get('column_config', [])
|
||||
grouping_config = data.get('grouping_config', {})
|
||||
chart_config = data.get('chart_config', {})
|
||||
sort_config = data.get('sort_config', [])
|
||||
|
||||
# Apply user's hospital restriction
|
||||
user = request.user
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
filter_config['hospital'] = str(user.hospital.id)
|
||||
|
||||
# Generate report data
|
||||
report_data = ReportBuilderService.generate_report_data(
|
||||
data_source=data_source,
|
||||
filter_config=filter_config,
|
||||
column_config=column_config,
|
||||
grouping_config=grouping_config,
|
||||
sort_config=sort_config
|
||||
)
|
||||
|
||||
# Generate summary
|
||||
summary = ReportBuilderService.generate_summary(data_source, filter_config)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'data': report_data,
|
||||
'summary': summary,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def save_report(request):
|
||||
"""Save a report configuration."""
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'error': 'POST required'}, status=405)
|
||||
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({'error': 'Invalid JSON'}, status=400)
|
||||
|
||||
report_id = data.get('id')
|
||||
|
||||
if report_id:
|
||||
# Update existing report
|
||||
report = get_object_or_404(SavedReport, id=report_id, created_by=request.user)
|
||||
report.name = data.get('name', report.name)
|
||||
report.description = data.get('description', report.description)
|
||||
report.data_source = data.get('data_source', report.data_source)
|
||||
report.filter_config = data.get('filter_config', report.filter_config)
|
||||
report.column_config = data.get('column_config', report.column_config)
|
||||
report.grouping_config = data.get('grouping_config', report.grouping_config)
|
||||
report.sort_config = data.get('sort_config', report.sort_config)
|
||||
report.is_shared = data.get('is_shared', report.is_shared)
|
||||
report.save()
|
||||
else:
|
||||
# Create new report
|
||||
report = SavedReport.objects.create(
|
||||
name=data.get('name', 'Untitled Report'),
|
||||
description=data.get('description', ''),
|
||||
data_source=data.get('data_source', 'complaints'),
|
||||
filter_config=data.get('filter_config', {}),
|
||||
column_config=data.get('column_config', []),
|
||||
grouping_config=data.get('grouping_config', {}),
|
||||
sort_config=data.get('sort_config', []),
|
||||
is_shared=data.get('is_shared', False),
|
||||
created_by=request.user,
|
||||
hospital=request.user.hospital,
|
||||
)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'report_id': str(report.id),
|
||||
'message': 'Report saved successfully'
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def saved_reports_list(request):
|
||||
"""List all saved reports."""
|
||||
user = request.user
|
||||
|
||||
# Get user's reports and shared reports
|
||||
queryset = SavedReport.objects.filter(
|
||||
created_by=user
|
||||
) | SavedReport.objects.filter(
|
||||
is_shared=True,
|
||||
hospital=user.hospital
|
||||
)
|
||||
|
||||
# Remove duplicates and order
|
||||
queryset = queryset.distinct().order_by('-created_at')
|
||||
|
||||
# Filter by data source
|
||||
data_source = request.GET.get('data_source')
|
||||
if data_source:
|
||||
queryset = queryset.filter(data_source=data_source)
|
||||
|
||||
# Search
|
||||
search = request.GET.get('search', '')
|
||||
if search:
|
||||
queryset = queryset.filter(name__icontains=search)
|
||||
|
||||
# Pagination
|
||||
paginator = Paginator(queryset, 25)
|
||||
page_number = request.GET.get('page', 1)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
|
||||
context = {
|
||||
'page_obj': page_obj,
|
||||
'reports': page_obj.object_list,
|
||||
'data_sources': DataSource.choices,
|
||||
'search': search,
|
||||
'selected_source': data_source,
|
||||
}
|
||||
|
||||
return render(request, 'reports/saved_reports.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def report_detail(request, report_id):
|
||||
"""View a saved report with live data."""
|
||||
user = request.user
|
||||
|
||||
report = get_object_or_404(SavedReport, id=report_id)
|
||||
|
||||
# Check access
|
||||
if report.created_by != user and not (report.is_shared and report.hospital == user.hospital):
|
||||
if not user.is_px_admin():
|
||||
messages.error(request, "You don't have access to this report.")
|
||||
return redirect('reports:saved_reports')
|
||||
|
||||
# Apply user's hospital restriction
|
||||
filter_config = report.filter_config.copy()
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
filter_config['hospital'] = str(user.hospital.id)
|
||||
|
||||
# Generate report data
|
||||
report_data = ReportBuilderService.generate_report_data(
|
||||
data_source=report.data_source,
|
||||
filter_config=filter_config,
|
||||
column_config=report.column_config,
|
||||
grouping_config=report.grouping_config,
|
||||
sort_config=report.sort_config
|
||||
)
|
||||
|
||||
# Generate summary
|
||||
summary = ReportBuilderService.generate_summary(report.data_source, filter_config)
|
||||
|
||||
# Update last run
|
||||
report.last_run_at = timezone.now()
|
||||
report.last_run_count = len(report_data.get('rows', []))
|
||||
report.save(update_fields=['last_run_at', 'last_run_count'])
|
||||
|
||||
context = {
|
||||
'report': report,
|
||||
'data': report_data,
|
||||
'summary': summary,
|
||||
'source_fields': ReportBuilderService.SOURCE_FIELDS.get(report.data_source, {}),
|
||||
}
|
||||
|
||||
return render(request, 'reports/report_detail.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def delete_report(request, report_id):
|
||||
"""Delete a saved report."""
|
||||
report = get_object_or_404(SavedReport, id=report_id, created_by=request.user)
|
||||
|
||||
if request.method == 'POST':
|
||||
report.delete()
|
||||
messages.success(request, 'Report deleted successfully.')
|
||||
return redirect('reports:saved_reports')
|
||||
|
||||
return render(request, 'reports/report_confirm_delete.html', {'report': report})
|
||||
|
||||
|
||||
@login_required
|
||||
def export_report(request, report_id, export_format):
|
||||
"""Export a report to Excel, PDF, or CSV."""
|
||||
user = request.user
|
||||
|
||||
report = get_object_or_404(SavedReport, id=report_id)
|
||||
|
||||
# Check access
|
||||
if report.created_by != user and not (report.is_shared and report.hospital == user.hospital):
|
||||
if not user.is_px_admin():
|
||||
messages.error(request, "You don't have access to this report.")
|
||||
return redirect('reports:saved_reports')
|
||||
|
||||
# Apply user's hospital restriction
|
||||
filter_config = report.filter_config.copy()
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
filter_config['hospital'] = str(user.hospital.id)
|
||||
|
||||
# Generate report data
|
||||
report_data = ReportBuilderService.generate_report_data(
|
||||
data_source=report.data_source,
|
||||
filter_config=filter_config,
|
||||
column_config=report.column_config,
|
||||
grouping_config=report.grouping_config,
|
||||
sort_config=report.sort_config
|
||||
)
|
||||
|
||||
rows = report_data.get('rows', [])
|
||||
columns = report_data.get('columns', [])
|
||||
column_keys = report_data.get('column_keys', columns) # Use keys if available, fallback to labels
|
||||
|
||||
# Generate filename
|
||||
filename = f"{report.name.replace(' ', '_')}_{timezone.now().strftime('%Y%m%d')}"
|
||||
|
||||
# Export based on format
|
||||
if export_format == 'csv':
|
||||
return ReportExportService.export_to_csv(rows, columns, column_keys, filename)
|
||||
elif export_format == 'excel':
|
||||
return ReportExportService.export_to_excel(rows, columns, column_keys, filename)
|
||||
elif export_format == 'pdf':
|
||||
return ReportExportService.export_to_pdf(rows, columns, column_keys, report.name, filename)
|
||||
else:
|
||||
messages.error(request, f'Unsupported export format: {export_format}')
|
||||
return redirect('reports:report_detail', report_id=report_id)
|
||||
|
||||
|
||||
@login_required
|
||||
def report_templates(request):
|
||||
"""List available report templates."""
|
||||
templates = ReportTemplate.objects.filter(is_active=True).order_by('category', 'sort_order', 'name')
|
||||
|
||||
# Group by category
|
||||
categories = {}
|
||||
for template in templates:
|
||||
cat = template.category or 'General'
|
||||
if cat not in categories:
|
||||
categories[cat] = []
|
||||
categories[cat].append(template)
|
||||
|
||||
context = {
|
||||
'categories': categories,
|
||||
'templates': templates,
|
||||
}
|
||||
|
||||
return render(request, 'reports/report_templates.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def use_template(request, template_id):
|
||||
"""Create a report from a template."""
|
||||
template = get_object_or_404(ReportTemplate, id=template_id, is_active=True)
|
||||
|
||||
if request.method == 'POST':
|
||||
# Create report from template with overrides
|
||||
overrides = {
|
||||
'name': request.POST.get('name', f"{template.name} - {timezone.now().strftime('%Y-%m-%d')}"),
|
||||
}
|
||||
|
||||
# Apply any filter overrides from the form
|
||||
for key, value in request.POST.items():
|
||||
if key.startswith('filter_'):
|
||||
filter_key = key[7:] # Remove 'filter_' prefix
|
||||
if 'filter_config' not in overrides:
|
||||
overrides['filter_config'] = template.filter_config.copy()
|
||||
overrides['filter_config'][filter_key] = value
|
||||
|
||||
report = template.create_report(request.user, overrides)
|
||||
messages.success(request, f'Report created from template: {template.name}')
|
||||
return redirect('reports:report_detail', report_id=report.id)
|
||||
|
||||
# Get available filter options
|
||||
hospitals = Hospital.objects.filter(status='active')
|
||||
if not request.user.is_px_admin() and request.user.hospital:
|
||||
hospitals = hospitals.filter(id=request.user.hospital.id)
|
||||
|
||||
context = {
|
||||
'template': template,
|
||||
'hospitals': hospitals,
|
||||
'source_filters': ReportBuilderService.SOURCE_FILTERS.get(template.data_source, []),
|
||||
}
|
||||
|
||||
return render(request, 'reports/use_template.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def filter_options_api(request):
|
||||
"""API endpoint to get filter options for a data source."""
|
||||
data_source = request.GET.get('data_source', 'complaints')
|
||||
|
||||
options = {}
|
||||
|
||||
# Status options - use defined choices, not database queries
|
||||
if data_source == 'complaints':
|
||||
from apps.complaints.models import Complaint
|
||||
# Get unique status values from model choices
|
||||
options['status'] = [choice[0] for choice in Complaint.STATUS_CHOICES] if hasattr(Complaint, 'STATUS_CHOICES') else ['open', 'in_progress', 'resolved', 'closed']
|
||||
options['severity'] = ['low', 'medium', 'high', 'critical']
|
||||
options['priority'] = ['low', 'medium', 'high', 'urgent']
|
||||
# Get unique source types from model choices or use defaults
|
||||
options['source'] = ['walk_in', 'call', 'email', 'website', 'social_media', 'app']
|
||||
|
||||
elif data_source == 'inquiries':
|
||||
from apps.complaints.models import Complaint
|
||||
options['status'] = [choice[0] for choice in Complaint.STATUS_CHOICES] if hasattr(Complaint, 'STATUS_CHOICES') else ['open', 'in_progress', 'resolved', 'closed']
|
||||
|
||||
elif data_source == 'observations':
|
||||
from apps.observations.models import Observation, ObservationStatus
|
||||
options['status'] = [s.value for s in ObservationStatus]
|
||||
options['severity'] = ['low', 'medium', 'high', 'critical']
|
||||
|
||||
elif data_source == 'surveys':
|
||||
options['status'] = ['pending', 'sent', 'completed', 'expired']
|
||||
options['patient_type'] = ['inpatient', 'outpatient', 'emergency']
|
||||
options['journey_type'] = ['admission', 'discharge', 'visit']
|
||||
|
||||
elif data_source == 'px_actions':
|
||||
options['status'] = ['open', 'in_progress', 'completed', 'closed']
|
||||
options['priority'] = ['low', 'medium', 'high', 'urgent']
|
||||
|
||||
elif data_source == 'physicians':
|
||||
options['journey_type'] = ['inpatient', 'outpatient', 'emergency']
|
||||
|
||||
# Hospital options
|
||||
hospitals = Hospital.objects.filter(status='active')
|
||||
if not request.user.is_px_admin() and request.user.hospital:
|
||||
hospitals = hospitals.filter(id=request.user.hospital.id)
|
||||
options['hospitals'] = list(hospitals.values('id', 'name'))
|
||||
|
||||
# Department options (filtered by hospital if provided)
|
||||
hospital_id = request.GET.get('hospital')
|
||||
departments = Department.objects.filter(status='active')
|
||||
if hospital_id:
|
||||
departments = departments.filter(hospital_id=hospital_id)
|
||||
elif not request.user.is_px_admin() and request.user.hospital:
|
||||
departments = departments.filter(hospital=request.user.hospital)
|
||||
options['departments'] = list(departments.values('id', 'name'))
|
||||
|
||||
# Available columns for the data source
|
||||
fields = ReportBuilderService.SOURCE_FIELDS.get(data_source, {})
|
||||
# Default columns (first 8 fields)
|
||||
default_columns = list(fields.keys())[:8]
|
||||
options['columns'] = [
|
||||
{
|
||||
'key': key,
|
||||
'label': info['label'],
|
||||
'type': info['type'],
|
||||
'selected': key in default_columns
|
||||
}
|
||||
for key, info in fields.items()
|
||||
]
|
||||
|
||||
return JsonResponse(options)
|
||||
|
||||
|
||||
@login_required
|
||||
def available_fields_api(request):
|
||||
"""API endpoint to get available fields for a data source."""
|
||||
data_source = request.GET.get('data_source', 'complaints')
|
||||
|
||||
fields = ReportBuilderService.SOURCE_FIELDS.get(data_source, {})
|
||||
|
||||
return JsonResponse({
|
||||
'fields': {k: {'label': v['label'], 'type': v['type']} for k, v in fields.items()}
|
||||
})
|
||||
@ -10,11 +10,14 @@ from apps.standards.views import (
|
||||
standards_dashboard,
|
||||
department_standards_view,
|
||||
standard_detail,
|
||||
standard_create,
|
||||
standard_update,
|
||||
standard_delete,
|
||||
standard_compliance_update,
|
||||
standard_attachment_upload,
|
||||
standard_attachment_delete,
|
||||
standards_search,
|
||||
get_compliance_status,
|
||||
standard_create,
|
||||
create_compliance_ajax,
|
||||
update_compliance_ajax,
|
||||
source_list,
|
||||
@ -29,44 +32,41 @@ from apps.standards.views import (
|
||||
|
||||
# API Router
|
||||
router = DefaultRouter()
|
||||
router.register(r'sources', StandardSourceViewSet, basename='standard-source')
|
||||
router.register(r'categories', StandardCategoryViewSet, basename='standard-category')
|
||||
router.register(r'standards', StandardViewSet, basename='standard')
|
||||
router.register(r'compliance', StandardComplianceViewSet, basename='standard-compliance')
|
||||
router.register(r'attachments', StandardAttachmentViewSet, basename='standard-attachment')
|
||||
router.register(r"sources", StandardSourceViewSet, basename="standard-source")
|
||||
router.register(r"categories", StandardCategoryViewSet, basename="standard-category")
|
||||
router.register(r"standards", StandardViewSet, basename="standard")
|
||||
router.register(r"compliance", StandardComplianceViewSet, basename="standard-compliance")
|
||||
router.register(r"attachments", StandardAttachmentViewSet, basename="standard-attachment")
|
||||
|
||||
app_name = 'standards'
|
||||
app_name = "standards"
|
||||
|
||||
urlpatterns = [
|
||||
# API endpoints
|
||||
path('api/', include(router.urls)),
|
||||
|
||||
# API endpoint for compliance status
|
||||
path('api/compliance/<uuid:department_id>/<uuid:standard_id>/', get_compliance_status, name='compliance_status'),
|
||||
|
||||
# Custom AJAX endpoints (MUST be before router to take precedence)
|
||||
path("api/compliance/create/", create_compliance_ajax, name="compliance_create_ajax"),
|
||||
path("api/compliance/update/", update_compliance_ajax, name="compliance_update_ajax"),
|
||||
path("api/compliance/<uuid:department_id>/<uuid:standard_id>/", get_compliance_status, name="compliance_status"),
|
||||
# API endpoints (router)
|
||||
path("api/", include(router.urls)),
|
||||
# UI Views
|
||||
path('', standards_dashboard, name='dashboard'),
|
||||
path('search/', standards_search, name='search'),
|
||||
path('departments/<uuid:pk>/', department_standards_view, name='department_standards'),
|
||||
path('departments/<uuid:department_id>/create-standard/', standard_create, name='standard_create'),
|
||||
path('standards/create/', standard_create, name='standard_create_global'),
|
||||
path('standards/<uuid:pk>/', standard_detail, name='standard_detail'),
|
||||
path('compliance/<uuid:compliance_id>/update/', standard_compliance_update, name='standard_compliance_update'),
|
||||
path('attachments/upload/<uuid:compliance_id>/', standard_attachment_upload, name='attachment_upload'),
|
||||
|
||||
# AJAX endpoints
|
||||
path('api/compliance/create/', create_compliance_ajax, name='compliance_create_ajax'),
|
||||
path('api/compliance/update/', update_compliance_ajax, name='compliance_update_ajax'),
|
||||
|
||||
path("", standards_dashboard, name="dashboard"),
|
||||
path("search/", standards_search, name="search"),
|
||||
path("departments/<uuid:pk>/", department_standards_view, name="department_standards"),
|
||||
path("departments/<uuid:department_id>/create-standard/", standard_create, name="standard_create"),
|
||||
path("standards/create/", standard_create, name="standard_create_global"),
|
||||
path("standards/<uuid:pk>/update/", standard_update, name="standard_update"),
|
||||
path("standards/<uuid:pk>/delete/", standard_delete, name="standard_delete"),
|
||||
path("standards/<uuid:pk>/", standard_detail, name="standard_detail"),
|
||||
path("compliance/<uuid:compliance_id>/update/", standard_compliance_update, name="standard_compliance_update"),
|
||||
path("attachments/upload/<uuid:compliance_id>/", standard_attachment_upload, name="attachment_upload"),
|
||||
path("attachments/<uuid:pk>/delete/", standard_attachment_delete, name="attachment_delete"),
|
||||
# Source Management
|
||||
path('sources/', source_list, name='source_list'),
|
||||
path('sources/create/', source_create, name='source_create'),
|
||||
path('sources/<uuid:pk>/update/', source_update, name='source_update'),
|
||||
path('sources/<uuid:pk>/delete/', source_delete, name='source_delete'),
|
||||
|
||||
path("sources/", source_list, name="source_list"),
|
||||
path("sources/create/", source_create, name="source_create"),
|
||||
path("sources/<uuid:pk>/update/", source_update, name="source_update"),
|
||||
path("sources/<uuid:pk>/delete/", source_delete, name="source_delete"),
|
||||
# Category Management
|
||||
path('categories/', category_list, name='category_list'),
|
||||
path('categories/create/', category_create, name='category_create'),
|
||||
path('categories/<uuid:pk>/update/', category_update, name='category_update'),
|
||||
path('categories/<uuid:pk>/delete/', category_delete, name='category_delete'),
|
||||
path("categories/", category_list, name="category_list"),
|
||||
path("categories/create/", category_create, name="category_create"),
|
||||
path("categories/<uuid:pk>/update/", category_update, name="category_update"),
|
||||
path("categories/<uuid:pk>/delete/", category_delete, name="category_delete"),
|
||||
]
|
||||
|
||||
@ -10,117 +10,120 @@ from django.utils import timezone
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
from apps.standards.models import (
|
||||
StandardSource,
|
||||
StandardCategory,
|
||||
Standard,
|
||||
StandardCompliance,
|
||||
StandardAttachment
|
||||
)
|
||||
from apps.standards.models import StandardSource, StandardCategory, Standard, StandardCompliance, StandardAttachment
|
||||
from apps.organizations.models import Department
|
||||
from apps.standards.forms import (
|
||||
StandardSourceForm,
|
||||
StandardCategoryForm,
|
||||
StandardForm,
|
||||
StandardComplianceForm,
|
||||
StandardAttachmentForm
|
||||
StandardAttachmentForm,
|
||||
)
|
||||
|
||||
|
||||
# ==================== API ViewSets ====================
|
||||
|
||||
|
||||
class StandardSourceViewSet(viewsets.ModelViewSet):
|
||||
queryset = StandardSource.objects.all()
|
||||
filterset_fields = ['is_active']
|
||||
search_fields = ['name', 'name_ar', 'code']
|
||||
ordering = ['name']
|
||||
filterset_fields = ["is_active"]
|
||||
search_fields = ["name", "name_ar", "code"]
|
||||
ordering = ["name"]
|
||||
|
||||
def get_serializer_class(self):
|
||||
from apps.standards.serializers import StandardSourceSerializer
|
||||
|
||||
return StandardSourceSerializer
|
||||
|
||||
|
||||
class StandardCategoryViewSet(viewsets.ModelViewSet):
|
||||
queryset = StandardCategory.objects.all()
|
||||
filterset_fields = ['is_active']
|
||||
search_fields = ['name', 'name_ar']
|
||||
ordering = ['order', 'name']
|
||||
filterset_fields = ["is_active"]
|
||||
search_fields = ["name", "name_ar"]
|
||||
ordering = ["order", "name"]
|
||||
|
||||
def get_serializer_class(self):
|
||||
from apps.standards.serializers import StandardCategorySerializer
|
||||
|
||||
return StandardCategorySerializer
|
||||
|
||||
|
||||
class StandardViewSet(viewsets.ModelViewSet):
|
||||
queryset = Standard.objects.all()
|
||||
filterset_fields = ['source', 'category', 'department', 'is_active']
|
||||
search_fields = ['code', 'title', 'title_ar', 'description']
|
||||
ordering = ['source', 'category', 'code']
|
||||
filterset_fields = ["source", "category", "department", "is_active"]
|
||||
search_fields = ["code", "title", "title_ar", "description"]
|
||||
ordering = ["source", "category", "code"]
|
||||
|
||||
def get_serializer_class(self):
|
||||
from apps.standards.serializers import StandardSerializer
|
||||
|
||||
return StandardSerializer
|
||||
|
||||
|
||||
class StandardComplianceViewSet(viewsets.ModelViewSet):
|
||||
queryset = StandardCompliance.objects.all()
|
||||
filterset_fields = ['department', 'standard', 'status']
|
||||
search_fields = ['department__name', 'standard__code', 'notes']
|
||||
ordering = ['-created_at']
|
||||
filterset_fields = ["department", "standard", "status"]
|
||||
search_fields = ["department__name", "standard__code", "notes"]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def get_serializer_class(self):
|
||||
from apps.standards.serializers import StandardComplianceSerializer
|
||||
|
||||
return StandardComplianceSerializer
|
||||
|
||||
|
||||
class StandardAttachmentViewSet(viewsets.ModelViewSet):
|
||||
queryset = StandardAttachment.objects.all()
|
||||
filterset_fields = ['compliance']
|
||||
search_fields = ['filename', 'description']
|
||||
ordering = ['-uploaded_at']
|
||||
filterset_fields = ["compliance"]
|
||||
search_fields = ["filename", "description"]
|
||||
ordering = ["-uploaded_at"]
|
||||
|
||||
def get_serializer_class(self):
|
||||
from apps.standards.serializers import StandardAttachmentSerializer
|
||||
|
||||
return StandardAttachmentSerializer
|
||||
|
||||
|
||||
# ==================== UI Views ====================
|
||||
|
||||
|
||||
@login_required
|
||||
def standards_dashboard(request):
|
||||
"""Standards dashboard with statistics"""
|
||||
# Get current hospital from tenant_hospital (set by middleware)
|
||||
hospital = getattr(request, 'tenant_hospital', None)
|
||||
hospital = getattr(request, "tenant_hospital", None)
|
||||
if not hospital:
|
||||
return render(request, 'core/no_hospital_assigned.html')
|
||||
return render(request, "core/no_hospital_assigned.html")
|
||||
|
||||
departments = hospital.departments.filter(status='active')
|
||||
departments = hospital.departments.filter(status="active")
|
||||
|
||||
# Get compliance statistics
|
||||
compliance_records = StandardCompliance.objects.filter(
|
||||
department__hospital=hospital
|
||||
)
|
||||
compliance_records = StandardCompliance.objects.filter(department__hospital=hospital)
|
||||
|
||||
stats = {
|
||||
'total_standards': Standard.objects.filter(is_active=True).count(),
|
||||
'total_departments': departments.count(),
|
||||
'met': compliance_records.filter(status='met').count(),
|
||||
'partially_met': compliance_records.filter(status='partially_met').count(),
|
||||
'not_met': compliance_records.filter(status='not_met').count(),
|
||||
'not_assessed': compliance_records.filter(status='not_assessed').count(),
|
||||
"total_standards": Standard.objects.filter(is_active=True).count(),
|
||||
"total_departments": departments.count(),
|
||||
"met": compliance_records.filter(status="met").count(),
|
||||
"partially_met": compliance_records.filter(status="partially_met").count(),
|
||||
"not_met": compliance_records.filter(status="not_met").count(),
|
||||
"not_assessed": compliance_records.filter(status="not_assessed").count(),
|
||||
}
|
||||
|
||||
# Recent compliance updates
|
||||
recent_updates = compliance_records.order_by('-updated_at')[:10]
|
||||
recent_updates = compliance_records.order_by("-updated_at")[:10]
|
||||
|
||||
# Check if user is PX admin or Hospital Admin
|
||||
is_px_admin = request.user.is_px_admin() or request.user.is_hospital_admin()
|
||||
|
||||
context = {
|
||||
'hospital': hospital,
|
||||
'departments': departments,
|
||||
'stats': stats,
|
||||
'recent_updates': recent_updates,
|
||||
"hospital": hospital,
|
||||
"departments": departments,
|
||||
"stats": stats,
|
||||
"recent_updates": recent_updates,
|
||||
"is_px_admin": is_px_admin,
|
||||
}
|
||||
|
||||
return render(request, 'standards/dashboard.html', context)
|
||||
return render(request, "standards/dashboard.html", context)
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@ -130,39 +133,36 @@ def department_standards_view(request, pk):
|
||||
department = get_object_or_404(Department, pk=pk)
|
||||
|
||||
# Check if user is PX admin
|
||||
hospital = getattr(request, 'tenant_hospital', None)
|
||||
is_px_admin = request.user.is_superuser or (
|
||||
hasattr(request.user, 'hospital_user') and
|
||||
request.user.hospital_user.hospital == hospital and
|
||||
request.user.hospital_user.role == 'px_admin'
|
||||
) if hospital else False
|
||||
hospital = getattr(request, "tenant_hospital", None)
|
||||
is_px_admin = (request.user.is_px_admin() or request.user.is_hospital_admin()) if hospital else False
|
||||
|
||||
# Get all active standards (both department-specific and general)
|
||||
department_standards = Standard.objects.filter(is_active=True).filter(
|
||||
Q(department=department) | Q(department__isnull=True)
|
||||
).order_by('source', 'category', 'code')
|
||||
department_standards = (
|
||||
Standard.objects.filter(is_active=True)
|
||||
.filter(Q(department=department) | Q(department__isnull=True))
|
||||
.order_by("source", "category", "code")
|
||||
)
|
||||
|
||||
# Get compliance status for each standard
|
||||
standards_data = []
|
||||
for standard in department_standards:
|
||||
compliance = StandardCompliance.objects.filter(
|
||||
department=department,
|
||||
standard=standard
|
||||
).first()
|
||||
compliance = StandardCompliance.objects.filter(department=department, standard=standard).first()
|
||||
|
||||
standards_data.append({
|
||||
'standard': standard,
|
||||
'compliance': compliance,
|
||||
'attachment_count': compliance.attachments.count() if compliance else 0,
|
||||
})
|
||||
standards_data.append(
|
||||
{
|
||||
"standard": standard,
|
||||
"compliance": compliance,
|
||||
"attachment_count": compliance.attachments.count() if compliance else 0,
|
||||
}
|
||||
)
|
||||
|
||||
context = {
|
||||
'department': department,
|
||||
'standards_data': standards_data,
|
||||
'is_px_admin': is_px_admin,
|
||||
"department": department,
|
||||
"standards_data": standards_data,
|
||||
"is_px_admin": is_px_admin,
|
||||
}
|
||||
|
||||
return render(request, 'standards/department_standards.html', context)
|
||||
return render(request, "standards/department_standards.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
@ -171,16 +171,25 @@ def standard_detail(request, pk):
|
||||
standard = get_object_or_404(Standard, pk=pk)
|
||||
|
||||
# Get compliance records for all departments
|
||||
compliance_records = StandardCompliance.objects.filter(
|
||||
standard=standard
|
||||
).select_related('department', 'assessor').order_by('-created_at')
|
||||
compliance_records = (
|
||||
StandardCompliance.objects.filter(standard=standard)
|
||||
.select_related("department", "assessor")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
|
||||
# Check if user is PX admin
|
||||
hospital = getattr(request, "tenant_hospital", None)
|
||||
is_px_admin = False
|
||||
if hospital:
|
||||
is_px_admin = request.user.is_px_admin() or request.user.is_hospital_admin()
|
||||
|
||||
context = {
|
||||
'standard': standard,
|
||||
'compliance_records': compliance_records,
|
||||
"standard": standard,
|
||||
"compliance_records": compliance_records,
|
||||
"is_px_admin": is_px_admin,
|
||||
}
|
||||
|
||||
return render(request, 'standards/standard_detail.html', context)
|
||||
return render(request, "standards/standard_detail.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
@ -188,20 +197,20 @@ def standard_compliance_update(request, compliance_id):
|
||||
"""Update compliance status"""
|
||||
compliance = get_object_or_404(StandardCompliance, pk=compliance_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
if request.method == "POST":
|
||||
form = StandardComplianceForm(request.POST, instance=compliance)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect('standards:department_standards', pk=compliance.department.pk)
|
||||
return redirect("standards:department_standards", pk=compliance.department.pk)
|
||||
else:
|
||||
form = StandardComplianceForm(instance=compliance)
|
||||
|
||||
context = {
|
||||
'compliance': compliance,
|
||||
'form': form,
|
||||
"compliance": compliance,
|
||||
"form": form,
|
||||
}
|
||||
|
||||
return render(request, 'standards/compliance_form.html', context)
|
||||
return render(request, "standards/compliance_form.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
@ -209,48 +218,65 @@ def standard_attachment_upload(request, compliance_id):
|
||||
"""Upload attachment for compliance"""
|
||||
compliance = get_object_or_404(StandardCompliance, pk=compliance_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
if request.method == "POST":
|
||||
form = StandardAttachmentForm(request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
attachment = form.save(commit=False)
|
||||
attachment.compliance = compliance
|
||||
attachment.uploaded_by = request.user
|
||||
attachment.filename = request.FILES['file'].name
|
||||
attachment.filename = request.FILES["file"].name
|
||||
attachment.save()
|
||||
return redirect('standards:standard_compliance_update', compliance_id=compliance.pk)
|
||||
return redirect("standards:standard_compliance_update", compliance_id=compliance.pk)
|
||||
else:
|
||||
form = StandardAttachmentForm()
|
||||
|
||||
context = {
|
||||
'compliance': compliance,
|
||||
'form': form,
|
||||
"compliance": compliance,
|
||||
"form": form,
|
||||
}
|
||||
|
||||
return render(request, 'standards/attachment_upload.html', context)
|
||||
return render(request, "standards/attachment_upload.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def standard_attachment_delete(request, pk):
|
||||
"""Delete an attachment"""
|
||||
attachment = get_object_or_404(StandardAttachment, pk=pk)
|
||||
compliance_id = attachment.compliance.id
|
||||
|
||||
if request.method == "POST":
|
||||
attachment.delete()
|
||||
from django.contrib import messages
|
||||
|
||||
messages.success(request, "Attachment deleted successfully.")
|
||||
return redirect("standards:standard_compliance_update", compliance_id=compliance_id)
|
||||
|
||||
context = {"attachment": attachment}
|
||||
return render(request, "standards/attachment_confirm_delete.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def standards_search(request):
|
||||
"""Search standards"""
|
||||
query = request.GET.get('q', '')
|
||||
source_filter = request.GET.get('source', '')
|
||||
category_filter = request.GET.get('category', '')
|
||||
status_filter = request.GET.get('status', '')
|
||||
query = request.GET.get("q", "")
|
||||
source_filter = request.GET.get("source", "")
|
||||
category_filter = request.GET.get("category", "")
|
||||
status_filter = request.GET.get("status", "")
|
||||
|
||||
# Get current hospital from tenant_hospital (set by middleware)
|
||||
hospital = getattr(request, 'tenant_hospital', None)
|
||||
hospital = getattr(request, "tenant_hospital", None)
|
||||
if not hospital:
|
||||
return render(request, 'core/no_hospital_assigned.html')
|
||||
return render(request, "core/no_hospital_assigned.html")
|
||||
|
||||
# Build queryset
|
||||
standards = Standard.objects.filter(is_active=True)
|
||||
|
||||
if query:
|
||||
standards = standards.filter(
|
||||
Q(code__icontains=query) |
|
||||
Q(title__icontains=query) |
|
||||
Q(title_ar__icontains=query) |
|
||||
Q(description__icontains=query)
|
||||
Q(code__icontains=query)
|
||||
| Q(title__icontains=query)
|
||||
| Q(title_ar__icontains=query)
|
||||
| Q(description__icontains=query)
|
||||
)
|
||||
|
||||
if source_filter:
|
||||
@ -259,99 +285,169 @@ def standards_search(request):
|
||||
if category_filter:
|
||||
standards = standards.filter(category_id=category_filter)
|
||||
|
||||
standards = standards.select_related('source', 'category').order_by('source', 'category', 'code')
|
||||
standards = standards.select_related("source", "category").order_by("source", "category", "code")
|
||||
|
||||
# Get filters
|
||||
sources = StandardSource.objects.filter(is_active=True)
|
||||
categories = StandardCategory.objects.filter(is_active=True)
|
||||
|
||||
# Check if user is PX admin or Hospital Admin
|
||||
is_px_admin = request.user.is_px_admin() or request.user.is_hospital_admin()
|
||||
|
||||
context = {
|
||||
'hospital': hospital,
|
||||
'standards': standards,
|
||||
'query': query,
|
||||
'source_filter': source_filter,
|
||||
'category_filter': category_filter,
|
||||
'sources': sources,
|
||||
'categories': categories,
|
||||
"hospital": hospital,
|
||||
"standards": standards,
|
||||
"query": query,
|
||||
"source_filter": source_filter,
|
||||
"category_filter": category_filter,
|
||||
"sources": sources,
|
||||
"categories": categories,
|
||||
"is_px_admin": is_px_admin,
|
||||
}
|
||||
|
||||
return render(request, 'standards/search.html', context)
|
||||
return render(request, "standards/search.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def standard_create(request, department_id=None):
|
||||
"""Create a new standard (PX Admin only)"""
|
||||
# Check if user is PX admin
|
||||
hospital = getattr(request, 'tenant_hospital', None)
|
||||
hospital = getattr(request, "tenant_hospital", None)
|
||||
if not hospital:
|
||||
return render(request, 'core/no_hospital_assigned.html')
|
||||
return render(request, "core/no_hospital_assigned.html")
|
||||
|
||||
is_px_admin = request.user.is_superuser or (
|
||||
hasattr(request.user, 'hospital_user') and
|
||||
request.user.hospital_user.hospital == hospital and
|
||||
request.user.hospital_user.role == 'px_admin'
|
||||
)
|
||||
is_px_admin = request.user.is_px_admin() or request.user.is_hospital_admin()
|
||||
|
||||
if not is_px_admin:
|
||||
from django.contrib import messages
|
||||
messages.error(request, 'You do not have permission to create standards.')
|
||||
if department_id:
|
||||
return redirect('standards:department_standards', pk=department_id)
|
||||
return redirect('standards:dashboard')
|
||||
|
||||
if request.method == 'POST':
|
||||
messages.error(request, "You do not have permission to create standards.")
|
||||
if department_id:
|
||||
return redirect("standards:department_standards", pk=department_id)
|
||||
return redirect("standards:dashboard")
|
||||
|
||||
if request.method == "POST":
|
||||
form = StandardForm(request.POST)
|
||||
if form.is_valid():
|
||||
standard = form.save(commit=False)
|
||||
standard.save()
|
||||
from django.contrib import messages
|
||||
messages.success(request, 'Standard created successfully.')
|
||||
|
||||
messages.success(request, "Standard created successfully.")
|
||||
if department_id:
|
||||
return redirect('standards:department_standards', pk=department_id)
|
||||
return redirect('standards:dashboard')
|
||||
return redirect("standards:department_standards", pk=department_id)
|
||||
return redirect("standards:dashboard")
|
||||
else:
|
||||
form = StandardForm()
|
||||
# If department_id is provided, pre-select that department
|
||||
if department_id:
|
||||
from apps.organizations.models import Department
|
||||
|
||||
department = Department.objects.filter(pk=department_id).first()
|
||||
if department:
|
||||
form.fields['department'].initial = department
|
||||
form.fields["department"].initial = department
|
||||
|
||||
# Get all departments for the hospital
|
||||
departments = hospital.departments.filter(status='active')
|
||||
departments = hospital.departments.filter(status="active")
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'department_id': department_id,
|
||||
'departments': departments,
|
||||
'hospital': hospital,
|
||||
"form": form,
|
||||
"department_id": department_id,
|
||||
"departments": departments,
|
||||
"hospital": hospital,
|
||||
}
|
||||
|
||||
return render(request, 'standards/standard_form.html', context)
|
||||
return render(request, "standards/standard_form.html", context)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@login_required
|
||||
def standard_update(request, pk):
|
||||
"""Update a standard (PX Admin only)"""
|
||||
standard = get_object_or_404(Standard, pk=pk)
|
||||
|
||||
# Check if user is PX admin
|
||||
hospital = getattr(request, "tenant_hospital", None)
|
||||
if not hospital:
|
||||
return render(request, "core/no_hospital_assigned.html")
|
||||
|
||||
is_px_admin = request.user.is_px_admin() or request.user.is_hospital_admin()
|
||||
|
||||
if not is_px_admin:
|
||||
from django.contrib import messages
|
||||
|
||||
messages.error(request, "You do not have permission to update standards.")
|
||||
return redirect("standards:standard_detail", pk=pk)
|
||||
|
||||
if request.method == "POST":
|
||||
form = StandardForm(request.POST, instance=standard)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
from django.contrib import messages
|
||||
|
||||
messages.success(request, "Standard updated successfully.")
|
||||
return redirect("standards:standard_detail", pk=pk)
|
||||
else:
|
||||
form = StandardForm(instance=standard)
|
||||
|
||||
context = {
|
||||
"form": form,
|
||||
"standard": standard,
|
||||
"hospital": hospital,
|
||||
}
|
||||
|
||||
return render(request, "standards/standard_form.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def standard_delete(request, pk):
|
||||
"""Delete a standard (PX Admin only)"""
|
||||
standard = get_object_or_404(Standard, pk=pk)
|
||||
|
||||
# Check if user is PX admin
|
||||
hospital = getattr(request, "tenant_hospital", None)
|
||||
if not hospital:
|
||||
return render(request, "core/no_hospital_assigned.html")
|
||||
|
||||
is_px_admin = request.user.is_px_admin() or request.user.is_hospital_admin()
|
||||
|
||||
if not is_px_admin:
|
||||
from django.contrib import messages
|
||||
|
||||
messages.error(request, "You do not have permission to delete standards.")
|
||||
return redirect("standards:standard_detail", pk=pk)
|
||||
|
||||
if request.method == "POST":
|
||||
standard.delete()
|
||||
from django.contrib import messages
|
||||
|
||||
messages.success(request, "Standard deleted successfully.")
|
||||
return redirect("standards:dashboard")
|
||||
|
||||
context = {"standard": standard}
|
||||
return render(request, "standards/standard_confirm_delete.html", context)
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def create_compliance_ajax(request):
|
||||
"""Create compliance record via AJAX"""
|
||||
if not request.user.is_authenticated:
|
||||
return JsonResponse({'success': False, 'error': 'Authentication required'}, status=401)
|
||||
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'success': False, 'error': 'Invalid request method'})
|
||||
return JsonResponse({"success": False, "error": "Authentication required"}, status=401)
|
||||
|
||||
if request.method != "POST":
|
||||
return JsonResponse({"success": False, "error": "Invalid request method"})
|
||||
|
||||
# Parse JSON from request body
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({'success': False, 'error': 'Invalid JSON'})
|
||||
return JsonResponse({"success": False, "error": "Invalid JSON"})
|
||||
|
||||
department_id = data.get('department_id')
|
||||
standard_id = data.get('standard_id')
|
||||
department_id = data.get("department_id")
|
||||
standard_id = data.get("standard_id")
|
||||
|
||||
if not department_id or not standard_id:
|
||||
return JsonResponse({'success': False, 'error': 'Missing required fields'})
|
||||
return JsonResponse({"success": False, "error": "Missing required fields"})
|
||||
|
||||
try:
|
||||
department = Department.objects.get(pk=department_id)
|
||||
@ -362,58 +458,62 @@ def create_compliance_ajax(request):
|
||||
department=department,
|
||||
standard=standard,
|
||||
defaults={
|
||||
'assessor': request.user,
|
||||
'last_assessed_date': timezone.now().date(),
|
||||
}
|
||||
"assessor": request.user,
|
||||
"last_assessed_date": timezone.now().date(),
|
||||
},
|
||||
)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'compliance_id': compliance.id,
|
||||
'status': compliance.status,
|
||||
'created': created,
|
||||
})
|
||||
return JsonResponse(
|
||||
{
|
||||
"success": True,
|
||||
"compliance_id": compliance.id,
|
||||
"status": compliance.status,
|
||||
"created": created,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return JsonResponse({'success': False, 'error': str(e)})
|
||||
return JsonResponse({"success": False, "error": str(e)})
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def update_compliance_ajax(request):
|
||||
"""Update compliance record via AJAX"""
|
||||
if not request.user.is_authenticated:
|
||||
return JsonResponse({'success': False, 'error': 'Authentication required'}, status=401)
|
||||
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'success': False, 'error': 'Invalid request method'})
|
||||
return JsonResponse({"success": False, "error": "Authentication required"}, status=401)
|
||||
|
||||
if request.method != "POST":
|
||||
return JsonResponse({"success": False, "error": "Invalid request method"})
|
||||
|
||||
# Parse JSON from request body
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({'success': False, 'error': 'Invalid JSON'})
|
||||
return JsonResponse({"success": False, "error": "Invalid JSON"})
|
||||
|
||||
compliance_id = data.get('compliance_id')
|
||||
status = data.get('status')
|
||||
notes = data.get('notes', '')
|
||||
evidence_summary = data.get('evidence_summary', '')
|
||||
last_assessed_date_str = data.get('last_assessed_date')
|
||||
assessor_id = data.get('assessor_id')
|
||||
compliance_id = data.get("compliance_id")
|
||||
status = data.get("status")
|
||||
notes = data.get("notes", "")
|
||||
evidence_summary = data.get("evidence_summary", "")
|
||||
last_assessed_date_str = data.get("last_assessed_date")
|
||||
assessor_id = data.get("assessor_id")
|
||||
|
||||
if not compliance_id or not status:
|
||||
return JsonResponse({'success': False, 'error': 'Missing required fields'})
|
||||
return JsonResponse({"success": False, "error": "Missing required fields"})
|
||||
|
||||
try:
|
||||
compliance = StandardCompliance.objects.get(pk=compliance_id)
|
||||
compliance.status = status
|
||||
compliance.notes = notes
|
||||
compliance.evidence_summary = evidence_summary
|
||||
|
||||
|
||||
# Set assessor - use logged-in user or provided ID
|
||||
if assessor_id:
|
||||
from apps.accounts.models import User
|
||||
|
||||
try:
|
||||
assessor = User.objects.get(pk=assessor_id)
|
||||
compliance.assessor = assessor
|
||||
@ -421,49 +521,49 @@ def update_compliance_ajax(request):
|
||||
compliance.assessor = request.user
|
||||
else:
|
||||
compliance.assessor = request.user
|
||||
|
||||
|
||||
# Set assessment date
|
||||
if last_assessed_date_str:
|
||||
compliance.last_assessed_date = datetime.strptime(last_assessed_date_str, '%Y-%m-%d').date()
|
||||
compliance.last_assessed_date = datetime.strptime(last_assessed_date_str, "%Y-%m-%d").date()
|
||||
else:
|
||||
compliance.last_assessed_date = timezone.now().date()
|
||||
|
||||
|
||||
compliance.save()
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'status': compliance.status,
|
||||
'status_display': compliance.get_status_display(),
|
||||
})
|
||||
return JsonResponse(
|
||||
{
|
||||
"success": True,
|
||||
"status": compliance.status,
|
||||
"status_display": compliance.get_status_display(),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return JsonResponse({'success': False, 'error': str(e)})
|
||||
return JsonResponse({"success": False, "error": str(e)})
|
||||
|
||||
|
||||
@login_required
|
||||
def get_compliance_status(request, department_id, standard_id):
|
||||
"""API endpoint to get compliance status"""
|
||||
compliance = StandardCompliance.objects.filter(
|
||||
department_id=department_id,
|
||||
standard_id=standard_id
|
||||
).first()
|
||||
compliance = StandardCompliance.objects.filter(department_id=department_id, standard_id=standard_id).first()
|
||||
|
||||
if compliance:
|
||||
data = {
|
||||
'status': compliance.status,
|
||||
'last_assessed_date': compliance.last_assessed_date,
|
||||
'assessor': compliance.assessor.get_full_name() if compliance.assessor else None,
|
||||
'notes': compliance.notes,
|
||||
'attachment_count': compliance.attachments.count(),
|
||||
"status": compliance.status,
|
||||
"last_assessed_date": compliance.last_assessed_date,
|
||||
"assessor": compliance.assessor.get_full_name() if compliance.assessor else None,
|
||||
"notes": compliance.notes,
|
||||
"attachment_count": compliance.attachments.count(),
|
||||
}
|
||||
else:
|
||||
data = {
|
||||
'status': 'not_assessed',
|
||||
'last_assessed_date': None,
|
||||
'assessor': None,
|
||||
'notes': '',
|
||||
'attachment_count': 0,
|
||||
"status": "not_assessed",
|
||||
"last_assessed_date": None,
|
||||
"assessor": None,
|
||||
"notes": "",
|
||||
"attachment_count": 0,
|
||||
}
|
||||
|
||||
return JsonResponse(data)
|
||||
@ -471,121 +571,129 @@ def get_compliance_status(request, department_id, standard_id):
|
||||
|
||||
# ==================== Source Management Views ====================
|
||||
|
||||
|
||||
@login_required
|
||||
def source_list(request):
|
||||
"""List all standard sources"""
|
||||
sources = StandardSource.objects.all().order_by('name')
|
||||
context = {'sources': sources}
|
||||
return render(request, 'standards/source_list.html', context)
|
||||
sources = StandardSource.objects.all().order_by("name")
|
||||
context = {"sources": sources}
|
||||
return render(request, "standards/source_list.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def source_create(request):
|
||||
"""Create a new standard source"""
|
||||
if request.method == 'POST':
|
||||
if request.method == "POST":
|
||||
form = StandardSourceForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
from django.contrib import messages
|
||||
messages.success(request, 'Source created successfully.')
|
||||
return redirect('standards:source_list')
|
||||
|
||||
messages.success(request, "Source created successfully.")
|
||||
return redirect("standards:source_list")
|
||||
else:
|
||||
form = StandardSourceForm()
|
||||
|
||||
context = {'form': form}
|
||||
return render(request, 'standards/source_form.html', context)
|
||||
context = {"form": form}
|
||||
return render(request, "standards/source_form.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def source_update(request, pk):
|
||||
"""Update a standard source"""
|
||||
source = get_object_or_404(StandardSource, pk=pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
|
||||
if request.method == "POST":
|
||||
form = StandardSourceForm(request.POST, instance=source)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
from django.contrib import messages
|
||||
messages.success(request, 'Source updated successfully.')
|
||||
return redirect('standards:source_list')
|
||||
|
||||
messages.success(request, "Source updated successfully.")
|
||||
return redirect("standards:source_list")
|
||||
else:
|
||||
form = StandardSourceForm(instance=source)
|
||||
|
||||
context = {'form': form, 'source': source}
|
||||
return render(request, 'standards/source_form.html', context)
|
||||
context = {"form": form, "source": source}
|
||||
return render(request, "standards/source_form.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def source_delete(request, pk):
|
||||
"""Delete a standard source"""
|
||||
source = get_object_or_404(StandardSource, pk=pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
|
||||
if request.method == "POST":
|
||||
source.delete()
|
||||
from django.contrib import messages
|
||||
messages.success(request, 'Source deleted successfully.')
|
||||
return redirect('standards:source_list')
|
||||
|
||||
context = {'source': source}
|
||||
return render(request, 'standards/source_confirm_delete.html', context)
|
||||
|
||||
messages.success(request, "Source deleted successfully.")
|
||||
return redirect("standards:source_list")
|
||||
|
||||
context = {"source": source}
|
||||
return render(request, "standards/source_confirm_delete.html", context)
|
||||
|
||||
|
||||
# ==================== Category Management Views ====================
|
||||
|
||||
|
||||
@login_required
|
||||
def category_list(request):
|
||||
"""List all standard categories"""
|
||||
categories = StandardCategory.objects.all().order_by('order', 'name')
|
||||
context = {'categories': categories}
|
||||
return render(request, 'standards/category_list.html', context)
|
||||
categories = StandardCategory.objects.all().order_by("order", "name")
|
||||
context = {"categories": categories}
|
||||
return render(request, "standards/category_list.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def category_create(request):
|
||||
"""Create a new standard category"""
|
||||
if request.method == 'POST':
|
||||
if request.method == "POST":
|
||||
form = StandardCategoryForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
from django.contrib import messages
|
||||
messages.success(request, 'Category created successfully.')
|
||||
return redirect('standards:category_list')
|
||||
|
||||
messages.success(request, "Category created successfully.")
|
||||
return redirect("standards:category_list")
|
||||
else:
|
||||
form = StandardCategoryForm()
|
||||
|
||||
context = {'form': form}
|
||||
return render(request, 'standards/category_form.html', context)
|
||||
context = {"form": form}
|
||||
return render(request, "standards/category_form.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def category_update(request, pk):
|
||||
"""Update a standard category"""
|
||||
category = get_object_or_404(StandardCategory, pk=pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
|
||||
if request.method == "POST":
|
||||
form = StandardCategoryForm(request.POST, instance=category)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
from django.contrib import messages
|
||||
messages.success(request, 'Category updated successfully.')
|
||||
return redirect('standards:category_list')
|
||||
|
||||
messages.success(request, "Category updated successfully.")
|
||||
return redirect("standards:category_list")
|
||||
else:
|
||||
form = StandardCategoryForm(instance=category)
|
||||
|
||||
context = {'form': form, 'category': category}
|
||||
return render(request, 'standards/category_form.html', context)
|
||||
context = {"form": form, "category": category}
|
||||
return render(request, "standards/category_form.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def category_delete(request, pk):
|
||||
"""Delete a standard category"""
|
||||
category = get_object_or_404(StandardCategory, pk=pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
|
||||
if request.method == "POST":
|
||||
category.delete()
|
||||
from django.contrib import messages
|
||||
messages.success(request, 'Category deleted successfully.')
|
||||
return redirect('standards:category_list')
|
||||
|
||||
context = {'category': category}
|
||||
return render(request, 'standards/category_confirm_delete.html', context)
|
||||
|
||||
messages.success(request, "Category deleted successfully.")
|
||||
return redirect("standards:category_list")
|
||||
|
||||
context = {"category": category}
|
||||
return render(request, "standards/category_confirm_delete.html", context)
|
||||
|
||||
@ -5,11 +5,18 @@ from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.organizations.models import Patient, Staff, Hospital
|
||||
from apps.core.form_mixins import HospitalFieldMixin
|
||||
from .models import SurveyInstance, SurveyTemplate, SurveyQuestion
|
||||
|
||||
|
||||
class SurveyTemplateForm(forms.ModelForm):
|
||||
"""Form for creating/editing survey templates"""
|
||||
class SurveyTemplateForm(HospitalFieldMixin, forms.ModelForm):
|
||||
"""
|
||||
Form for creating/editing survey templates.
|
||||
|
||||
Hospital field visibility:
|
||||
- PX Admins: See dropdown with all hospitals
|
||||
- Others: Hidden field, auto-set to user's hospital
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = SurveyTemplate
|
||||
@ -260,8 +267,14 @@ class BulkCSVSurveySendForm(forms.Form):
|
||||
)
|
||||
|
||||
|
||||
class HISPatientImportForm(forms.Form):
|
||||
"""Form for importing patient data from HIS/MOH Statistics CSV"""
|
||||
class HISPatientImportForm(HospitalFieldMixin, forms.Form):
|
||||
"""
|
||||
Form for importing patient data from HIS/MOH Statistics CSV.
|
||||
|
||||
Hospital field visibility:
|
||||
- PX Admins: See dropdown with all hospitals
|
||||
- Others: Hidden field, auto-set to user's hospital
|
||||
"""
|
||||
|
||||
hospital = forms.ModelChoiceField(
|
||||
queryset=Hospital.objects.filter(status='active'),
|
||||
@ -291,16 +304,6 @@ class HISPatientImportForm(forms.Form):
|
||||
}),
|
||||
help_text=_('Number of metadata/header rows to skip before data rows')
|
||||
)
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.user = user
|
||||
# Filter hospitals by user's access
|
||||
if user.hospital and not user.is_px_admin():
|
||||
self.fields['hospital'].queryset = Hospital.objects.filter(
|
||||
id=user.hospital.id,
|
||||
status='active'
|
||||
)
|
||||
|
||||
|
||||
class HISSurveySendForm(forms.Form):
|
||||
|
||||
@ -10,6 +10,7 @@ import logging
|
||||
from datetime import datetime
|
||||
|
||||
from django.contrib import messages
|
||||
from django.utils import timezone
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
from django.db import transaction
|
||||
@ -49,7 +50,7 @@ def his_patient_import(request):
|
||||
session_key = f'his_import_{user.id}'
|
||||
|
||||
if request.method == 'POST':
|
||||
form = HISPatientImportForm(user, request.POST, request.FILES)
|
||||
form = HISPatientImportForm(request.POST, request.FILES, user=user)
|
||||
|
||||
if form.is_valid():
|
||||
try:
|
||||
@ -207,7 +208,7 @@ def his_patient_import(request):
|
||||
logger.error(f"Error processing HIS CSV: {str(e)}", exc_info=True)
|
||||
messages.error(request, f"Error processing CSV: {str(e)}")
|
||||
else:
|
||||
form = HISPatientImportForm(user)
|
||||
form = HISPatientImportForm(user=user)
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
@ -318,7 +319,7 @@ def his_patient_survey_send(request):
|
||||
return redirect('surveys:his_patient_review')
|
||||
|
||||
if request.method == 'POST':
|
||||
form = HISSurveySendForm(user, request.POST)
|
||||
form = HISSurveySendForm(request.POST, user=user)
|
||||
|
||||
if form.is_valid():
|
||||
survey_template = form.cleaned_data['survey_template']
|
||||
@ -378,7 +379,7 @@ def his_patient_survey_send(request):
|
||||
)
|
||||
return redirect('surveys:bulk_job_status', job_id=job.id)
|
||||
else:
|
||||
form = HISSurveySendForm(user)
|
||||
form = HISSurveySendForm(user=user)
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
|
||||
@ -13,6 +13,7 @@ from django.views.decorators.http import require_http_methods
|
||||
from django.db.models import ExpressionWrapper, FloatField
|
||||
|
||||
from apps.core.services import AuditService
|
||||
from apps.core.decorators import block_source_user
|
||||
from apps.organizations.models import Department, Hospital
|
||||
|
||||
from .forms import ManualSurveySendForm, SurveyQuestionFormSet, SurveyTemplateForm, ManualPhoneSurveySendForm, BulkCSVSurveySendForm
|
||||
@ -21,6 +22,7 @@ from .models import SurveyInstance, SurveyTemplate, SurveyQuestion
|
||||
from .tasks import send_satisfaction_feedback
|
||||
from datetime import datetime
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def survey_instance_list(request):
|
||||
"""
|
||||
@ -32,6 +34,11 @@ def survey_instance_list(request):
|
||||
- Search by patient MRN
|
||||
- Score display
|
||||
"""
|
||||
# Source Users don't have access to surveys
|
||||
if request.user.is_source_user():
|
||||
from django.core.exceptions import PermissionDenied
|
||||
raise PermissionDenied("Source users do not have access to surveys.")
|
||||
|
||||
# Base queryset with optimizations
|
||||
queryset = SurveyInstance.objects.select_related(
|
||||
'survey_template',
|
||||
@ -112,6 +119,7 @@ def survey_instance_list(request):
|
||||
return render(request, 'surveys/instance_list.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def survey_instance_detail(request, pk):
|
||||
"""
|
||||
@ -209,6 +217,7 @@ def survey_instance_detail(request, pk):
|
||||
return render(request, 'surveys/instance_detail.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def survey_template_list(request):
|
||||
"""Survey templates list view"""
|
||||
@ -262,6 +271,7 @@ def survey_template_list(request):
|
||||
return render(request, 'surveys/template_list.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def survey_template_create(request):
|
||||
"""Create a new survey template with questions"""
|
||||
@ -272,7 +282,7 @@ def survey_template_create(request):
|
||||
return redirect('surveys:template_list')
|
||||
|
||||
if request.method == 'POST':
|
||||
form = SurveyTemplateForm(request.POST)
|
||||
form = SurveyTemplateForm(request.POST, user=user)
|
||||
formset = SurveyQuestionFormSet(request.POST)
|
||||
|
||||
if form.is_valid() and formset.is_valid():
|
||||
@ -288,7 +298,7 @@ def survey_template_create(request):
|
||||
messages.success(request, "Survey template created successfully.")
|
||||
return redirect('surveys:template_detail', pk=template.pk)
|
||||
else:
|
||||
form = SurveyTemplateForm()
|
||||
form = SurveyTemplateForm(user=user)
|
||||
formset = SurveyQuestionFormSet()
|
||||
|
||||
context = {
|
||||
@ -299,6 +309,7 @@ def survey_template_create(request):
|
||||
return render(request, 'surveys/template_form.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def survey_template_detail(request, pk):
|
||||
"""View survey template details"""
|
||||
@ -337,6 +348,7 @@ def survey_template_detail(request, pk):
|
||||
return render(request, 'surveys/template_detail.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def survey_template_edit(request, pk):
|
||||
"""Edit an existing survey template with questions"""
|
||||
@ -350,7 +362,7 @@ def survey_template_edit(request, pk):
|
||||
return redirect('surveys:template_list')
|
||||
|
||||
if request.method == 'POST':
|
||||
form = SurveyTemplateForm(request.POST, instance=template)
|
||||
form = SurveyTemplateForm(request.POST, instance=template, user=user)
|
||||
formset = SurveyQuestionFormSet(request.POST, instance=template)
|
||||
|
||||
if form.is_valid() and formset.is_valid():
|
||||
@ -360,7 +372,7 @@ def survey_template_edit(request, pk):
|
||||
messages.success(request, "Survey template updated successfully.")
|
||||
return redirect('surveys:template_detail', pk=template.pk)
|
||||
else:
|
||||
form = SurveyTemplateForm(instance=template)
|
||||
form = SurveyTemplateForm(instance=template, user=user)
|
||||
formset = SurveyQuestionFormSet(instance=template)
|
||||
|
||||
context = {
|
||||
@ -372,6 +384,7 @@ def survey_template_edit(request, pk):
|
||||
return render(request, 'surveys/template_form.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def survey_template_delete(request, pk):
|
||||
"""Delete a survey template"""
|
||||
@ -397,6 +410,7 @@ def survey_template_delete(request, pk):
|
||||
return render(request, 'surveys/template_confirm_delete.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
@require_http_methods(["POST"])
|
||||
def survey_log_patient_contact(request, pk):
|
||||
@ -462,6 +476,7 @@ def survey_log_patient_contact(request, pk):
|
||||
return redirect('surveys:instance_detail', pk=pk)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def survey_comments_list(request):
|
||||
"""
|
||||
@ -720,6 +735,7 @@ def survey_comments_list(request):
|
||||
return render(request, 'surveys/comment_list.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
@require_http_methods(["POST"])
|
||||
def survey_send_satisfaction_feedback(request, pk):
|
||||
@ -769,6 +785,7 @@ def survey_send_satisfaction_feedback(request, pk):
|
||||
return redirect('surveys:instance_detail', pk=pk)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def manual_survey_send(request):
|
||||
"""
|
||||
@ -889,6 +906,7 @@ def manual_survey_send(request):
|
||||
return render(request, 'surveys/manual_send.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def manual_survey_send_phone(request):
|
||||
"""
|
||||
@ -979,6 +997,7 @@ def manual_survey_send_phone(request):
|
||||
return render(request, 'surveys/manual_send_phone.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def manual_survey_send_csv(request):
|
||||
"""
|
||||
@ -1162,6 +1181,7 @@ def manual_survey_send_csv(request):
|
||||
return render(request, 'surveys/manual_send_csv.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def survey_analytics_reports(request):
|
||||
"""
|
||||
@ -1233,6 +1253,7 @@ def survey_analytics_reports(request):
|
||||
return render(request, 'surveys/analytics_reports.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def survey_analytics_report_view(request, filename):
|
||||
"""
|
||||
@ -1307,6 +1328,7 @@ def survey_analytics_report_view(request, filename):
|
||||
return render(request, 'surveys/analytics_report_info.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def survey_analytics_report_download(request, filename):
|
||||
"""
|
||||
@ -1353,6 +1375,7 @@ def survey_analytics_report_download(request, filename):
|
||||
)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def survey_analytics_report_view_inline(request, filename):
|
||||
"""
|
||||
@ -1431,6 +1454,7 @@ def survey_analytics_report_view_inline(request, filename):
|
||||
)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
@require_http_methods(["POST"])
|
||||
def survey_analytics_report_delete(request, filename):
|
||||
@ -1482,6 +1506,7 @@ def _human_readable_size(size_bytes):
|
||||
# ENHANCED SURVEY REPORTS - Separate reports per survey type
|
||||
# ============================================================================
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def enhanced_survey_reports_list(request):
|
||||
"""
|
||||
@ -1527,6 +1552,7 @@ def enhanced_survey_reports_list(request):
|
||||
return render(request, 'surveys/enhanced_reports_list.html', context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def enhanced_survey_report_view(request, dir_name):
|
||||
"""
|
||||
@ -1565,6 +1591,7 @@ def enhanced_survey_report_view(request, dir_name):
|
||||
return redirect('surveys:enhanced_reports_list')
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def enhanced_survey_report_file(request, dir_name, filename):
|
||||
"""
|
||||
@ -1608,6 +1635,7 @@ def enhanced_survey_report_file(request, dir_name, filename):
|
||||
return FileResponse(open(filepath, 'rb'), as_attachment=True)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def generate_enhanced_report_ui(request):
|
||||
"""
|
||||
|
||||
@ -27,6 +27,14 @@ app.conf.beat_schedule = {
|
||||
'task': 'apps.integrations.tasks.process_pending_events',
|
||||
'schedule': crontab(minute='*/1'),
|
||||
},
|
||||
# Fetch surveys from HIS every 5 minutes
|
||||
'fetch-his-surveys': {
|
||||
'task': 'apps.integrations.tasks.fetch_his_surveys',
|
||||
'schedule': crontab(minute='*/5'), # Every 5 minutes
|
||||
'options': {
|
||||
'expires': 240, # Task expires after 4 minutes if not picked up
|
||||
}
|
||||
},
|
||||
# Check for overdue complaints every 15 minutes
|
||||
'check-overdue-complaints': {
|
||||
'task': 'apps.complaints.tasks.check_overdue_complaints',
|
||||
@ -76,7 +84,6 @@ app.conf.beat_schedule = {
|
||||
|
||||
|
||||
|
||||
|
||||
# Scraping schedules
|
||||
'scrape-youtube-hourly': {
|
||||
'task': 'social.tasks.scrape_youtube_comments',
|
||||
|
||||
@ -69,6 +69,7 @@ LOCAL_APPS = [
|
||||
'apps.references',
|
||||
'apps.standards',
|
||||
'apps.simulator',
|
||||
'apps.reports',
|
||||
]
|
||||
|
||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||
@ -82,6 +83,7 @@ MIDDLEWARE = [
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'apps.core.middleware.TenantMiddleware', # Multi-tenancy support
|
||||
'apps.px_sources.middleware.SourceUserRestrictionMiddleware', # STRICT source user restrictions
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
@ -45,6 +45,7 @@ urlpatterns = [
|
||||
path('px-sources/', include('apps.px_sources.urls')),
|
||||
path('references/', include('apps.references.urls', namespace='references')),
|
||||
path('standards/', include('apps.standards.urls')),
|
||||
path('reports/', include('apps.reports.urls', namespace='reports')),
|
||||
|
||||
# API endpoints
|
||||
path('api/auth/', include('apps.accounts.urls', namespace='api_auth')),
|
||||
|
||||
153
templates/accounts/acknowledgements/category_form.html
Normal file
153
templates/accounts/acknowledgements/category_form.html
Normal file
@ -0,0 +1,153 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% if form.instance.pk %}{% trans "Edit Category" %}{% else %}{% trans "New Category" %}{% endif %} - PX360{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
|
||||
<!-- Page Header -->
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-blue to-indigo-500 rounded-2xl shadow-lg shadow-blue-200">
|
||||
<i data-lucide="{% if form.instance.pk %}edit{% else %}plus{% endif %}" class="w-8 h-8 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-navy">
|
||||
{% if form.instance.pk %}{% trans "Edit Category" %}{% else %}{% trans "New Category" %}{% endif %}
|
||||
</h1>
|
||||
<p class="text-slate text-sm">
|
||||
{% if form.instance.pk %}{{ form.instance.name_en }}{% else %}{% trans "Create a new acknowledgement category" %}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{% url 'accounts:acknowledgements:ack_category_list' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white border-2 border-slate-200 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||
{% trans "Back to Categories" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Form Card -->
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
|
||||
<div class="px-6 py-5 border-b border-slate-100 bg-gradient-to-r from-blue-50 to-transparent">
|
||||
<h2 class="text-lg font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="file-text" class="w-5 h-5 text-blue-500"></i>
|
||||
{% trans "Category Details" %}
|
||||
</h2>
|
||||
</div>
|
||||
<form method="post" class="p-6 space-y-6">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.name_en.id_for_label }}">
|
||||
{% trans "Name (English)" %}
|
||||
</label>
|
||||
{{ form.name_en }}
|
||||
{% if form.name_en.errors %}
|
||||
<p class="mt-1 text-sm text-red-500">{{ form.name_en.errors.0 }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.name_ar.id_for_label }}">
|
||||
{% trans "Name (Arabic)" %}
|
||||
</label>
|
||||
{{ form.name_ar }}
|
||||
{% if form.name_ar.errors %}
|
||||
<p class="mt-1 text-sm text-red-500">{{ form.name_ar.errors.0 }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.code.id_for_label }}">
|
||||
{% trans "Code" %}
|
||||
</label>
|
||||
{{ form.code }}
|
||||
{% if form.code.errors %}
|
||||
<p class="mt-1 text-sm text-red-500">{{ form.code.errors.0 }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.order.id_for_label }}">
|
||||
{% trans "Display Order" %}
|
||||
</label>
|
||||
{{ form.order }}
|
||||
{% if form.order.errors %}
|
||||
<p class="mt-1 text-sm text-red-500">{{ form.order.errors.0 }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.icon.id_for_label }}">
|
||||
{% trans "Icon" %}
|
||||
</label>
|
||||
{{ form.icon }}
|
||||
{% if form.icon.errors %}
|
||||
<p class="mt-1 text-sm text-red-500">{{ form.icon.errors.0 }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.color.id_for_label }}">
|
||||
{% trans "Color" %}
|
||||
</label>
|
||||
<div class="flex gap-3 items-center">
|
||||
{{ form.color }}
|
||||
<input type="color" id="color_picker" value="{{ form.color.value|default:'#3B82F6' }}" class="w-12 h-10 rounded-lg border border-slate-200 cursor-pointer">
|
||||
</div>
|
||||
{% if form.color.errors %}
|
||||
<p class="mt-1 text-sm text-red-500">{{ form.color.errors.0 }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.is_active.id_for_label }}">
|
||||
{% trans "Status" %}
|
||||
</label>
|
||||
<div class="flex items-center gap-3 mt-3">
|
||||
{{ form.is_active }}
|
||||
<span class="text-slate">{% trans "Active" %}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-6 border-t border-slate-100">
|
||||
<a href="{% url 'accounts:acknowledgements:ack_category_list' %}" class="px-6 py-2.5 bg-slate-100 text-slate-700 rounded-xl font-semibold hover:bg-slate-200 transition">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit" class="inline-flex items-center gap-2 px-6 py-2.5 bg-gradient-to-r from-blue to-navy text-white rounded-xl font-semibold hover:from-navy hover:to-blue transition shadow-lg shadow-blue-200">
|
||||
<i data-lucide="save" class="w-4 h-4"></i>
|
||||
{% trans "Save Category" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Sync color picker with text input
|
||||
const colorPicker = document.getElementById('color_picker');
|
||||
const colorInput = document.getElementById('{{ form.color.id_for_label }}');
|
||||
if (colorPicker && colorInput) {
|
||||
colorPicker.addEventListener('input', (e) => {
|
||||
colorInput.value = e.target.value;
|
||||
});
|
||||
colorInput.addEventListener('input', (e) => {
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(e.target.value)) {
|
||||
colorPicker.value = e.target.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
133
templates/accounts/acknowledgements/category_list.html
Normal file
133
templates/accounts/acknowledgements/category_list.html
Normal file
@ -0,0 +1,133 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Acknowledgement Categories" %} - PX360{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
|
||||
<!-- Page Header -->
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-blue to-indigo-500 rounded-2xl shadow-lg shadow-blue-200">
|
||||
<i data-lucide="folder" class="w-8 h-8 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-navy">
|
||||
{% trans "Acknowledgement Categories" %}
|
||||
</h1>
|
||||
<p class="text-slate text-sm">
|
||||
{% trans "Manage categories for acknowledgement items" %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<a href="{% url 'accounts:acknowledgements:ack_dashboard' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white border-2 border-slate-200 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||
{% trans "Back" %}
|
||||
</a>
|
||||
<a href="{% url 'accounts:acknowledgements:ack_category_create' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-blue to-navy text-white rounded-xl font-semibold hover:from-navy hover:to-blue transition shadow-lg shadow-blue-200">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||
{% trans "New Category" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Categories List -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
|
||||
<div class="px-6 py-5 border-b border-slate-100 bg-gradient-to-r from-blue-50 to-transparent">
|
||||
<h2 class="text-lg font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="list" class="w-5 h-5 text-blue-500"></i>
|
||||
{% trans "All Categories" %}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
{% if categories %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-200">
|
||||
<th class="text-left py-3 px-4 text-xs font-semibold text-slate uppercase">{% trans "Category" %}</th>
|
||||
<th class="text-left py-3 px-4 text-xs font-semibold text-slate uppercase">{% trans "Code" %}</th>
|
||||
<th class="text-left py-3 px-4 text-xs font-semibold text-slate uppercase">{% trans "Order" %}</th>
|
||||
<th class="text-left py-3 px-4 text-xs font-semibold text-slate uppercase">{% trans "Status" %}</th>
|
||||
<th class="text-right py-3 px-4 text-xs font-semibold text-slate uppercase">{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for category in categories %}
|
||||
<tr class="border-b border-slate-100 hover:bg-slate-50 transition">
|
||||
<td class="py-4 px-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center" style="background-color: {{ category.color }}20">
|
||||
<i data-lucide="{{ category.icon|default:'folder' }}" class="w-5 h-5" style="color: {{ category.color }}"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-navy">{{ category.name_en }}</p>
|
||||
{% if category.name_ar %}
|
||||
<p class="text-sm text-slate">{{ category.name_ar }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-4 px-4">
|
||||
<span class="inline-flex items-center px-2 py-1 bg-slate-100 text-slate-700 rounded-lg text-xs font-mono">
|
||||
{{ category.code|default:"-" }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-4 px-4 text-slate">{{ category.order }}</td>
|
||||
<td class="py-4 px-4">
|
||||
<button onclick="toggleCategory('{{ category.id }}')" class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition {% if category.is_active %}bg-emerald-100 text-emerald-700{% else %}bg-red-100 text-red-700{% endif %}">
|
||||
<i data-lucide="{% if category.is_active %}check-circle{% else %}x-circle{% endif %}" class="w-3.5 h-3.5"></i>
|
||||
{% if category.is_active %}{% trans "Active" %}{% else %}{% trans "Inactive" %}{% endif %}
|
||||
</button>
|
||||
</td>
|
||||
<td class="py-4 px-4 text-right">
|
||||
<a href="{% url 'accounts:acknowledgements:ack_category_edit' category.id %}" class="inline-flex items-center gap-1 px-3 py-1.5 bg-blue-50 text-blue-700 rounded-lg text-sm font-semibold hover:bg-blue-100 transition">
|
||||
<i data-lucide="edit-2" class="w-4 h-4"></i>
|
||||
{% trans "Edit" %}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-16">
|
||||
<div class="w-20 h-20 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i data-lucide="folder-open" class="w-10 h-10 text-blue-500"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-navy mb-2">{% trans "No Categories Yet" %}</h3>
|
||||
<p class="text-slate mb-6">{% trans "Create your first acknowledgement category to get started." %}</p>
|
||||
<a href="{% url 'accounts:acknowledgements:ack_category_create' %}" class="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-blue to-navy text-white rounded-xl font-semibold hover:from-navy hover:to-blue transition shadow-lg shadow-blue-200">
|
||||
<i data-lucide="plus" class="w-5 h-5"></i>
|
||||
{% trans "Create Category" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleCategory(categoryId) {
|
||||
fetch(`/accounts/acknowledgements/admin/categories/${categoryId}/toggle/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || '',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
156
templates/accounts/acknowledgements/checklist_form.html
Normal file
156
templates/accounts/acknowledgements/checklist_form.html
Normal file
@ -0,0 +1,156 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% if form.instance.pk %}{% trans "Edit Checklist Item" %}{% else %}{% trans "New Checklist Item" %}{% endif %} - PX360{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
|
||||
<!-- Page Header -->
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-2xl shadow-lg shadow-purple-200">
|
||||
<i data-lucide="{% if form.instance.pk %}edit{% else %}plus{% endif %}" class="w-8 h-8 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-navy">
|
||||
{% if form.instance.pk %}{% trans "Edit Checklist Item" %}{% else %}{% trans "New Checklist Item" %}{% endif %}
|
||||
</h1>
|
||||
<p class="text-slate text-sm">
|
||||
{% if form.instance.pk %}{{ form.instance.text_en }}{% else %}{% trans "Create a new acknowledgement checklist item" %}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{% url 'accounts:acknowledgements:ack_checklist_list' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white border-2 border-slate-200 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||
{% trans "Back to List" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Form Card -->
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
|
||||
<div class="px-6 py-5 border-b border-slate-100 bg-gradient-to-r from-purple-50 to-transparent">
|
||||
<h2 class="text-lg font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="file-text" class="w-5 h-5 text-purple-500"></i>
|
||||
{% trans "Checklist Item Details" %}
|
||||
</h2>
|
||||
</div>
|
||||
<form method="post" class="p-6 space-y-6">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.category.id_for_label }}">
|
||||
{% trans "Category" %} *
|
||||
</label>
|
||||
{{ form.category }}
|
||||
{% if form.category.errors %}
|
||||
<p class="mt-1 text-sm text-red-500">{{ form.category.errors.0 }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.code.id_for_label }}">
|
||||
{% trans "Code" %}
|
||||
</label>
|
||||
{{ form.code }}
|
||||
{% if form.code.errors %}
|
||||
<p class="mt-1 text-sm text-red-500">{{ form.code.errors.0 }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.text_en.id_for_label }}">
|
||||
{% trans "Text (English)" %} *
|
||||
</label>
|
||||
{{ form.text_en }}
|
||||
{% if form.text_en.errors %}
|
||||
<p class="mt-1 text-sm text-red-500">{{ form.text_en.errors.0 }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.text_ar.id_for_label }}">
|
||||
{% trans "Text (Arabic)" %}
|
||||
</label>
|
||||
{{ form.text_ar }}
|
||||
{% if form.text_ar.errors %}
|
||||
<p class="mt-1 text-sm text-red-500">{{ form.text_ar.errors.0 }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.description_en.id_for_label }}">
|
||||
{% trans "Description (English)" %}
|
||||
</label>
|
||||
{{ form.description_en }}
|
||||
{% if form.description_en.errors %}
|
||||
<p class="mt-1 text-sm text-red-500">{{ form.description_en.errors.0 }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.description_ar.id_for_label }}">
|
||||
{% trans "Description (Arabic)" %}
|
||||
</label>
|
||||
{{ form.description_ar }}
|
||||
{% if form.description_ar.errors %}
|
||||
<p class="mt-1 text-sm text-red-500">{{ form.description_ar.errors.0 }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.order.id_for_label }}">
|
||||
{% trans "Display Order" %}
|
||||
</label>
|
||||
{{ form.order }}
|
||||
{% if form.order.errors %}
|
||||
<p class="mt-1 text-sm text-red-500">{{ form.order.errors.0 }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="mt-6">
|
||||
<div class="flex items-center gap-3">
|
||||
{{ form.requires_signature }}
|
||||
<label class="text-sm text-navy" for="{{ form.requires_signature.id_for_label }}">
|
||||
{% trans "Requires Signature" %}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="mt-6">
|
||||
<div class="flex items-center gap-3">
|
||||
{{ form.is_active }}
|
||||
<label class="text-sm text-navy" for="{{ form.is_active.id_for_label }}">
|
||||
{% trans "Active" %}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-6 border-t border-slate-100">
|
||||
<a href="{% url 'accounts:acknowledgements:ack_checklist_list' %}" class="px-6 py-2.5 bg-slate-100 text-slate-700 rounded-xl font-semibold hover:bg-slate-200 transition">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit" class="inline-flex items-center gap-2 px-6 py-2.5 bg-gradient-to-r from-purple-500 to-indigo-500 text-white rounded-xl font-semibold hover:from-indigo-500 hover:to-purple-500 transition shadow-lg shadow-purple-200">
|
||||
<i data-lucide="save" class="w-4 h-4"></i>
|
||||
{% trans "Save Item" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
160
templates/accounts/acknowledgements/checklist_list.html
Normal file
160
templates/accounts/acknowledgements/checklist_list.html
Normal file
@ -0,0 +1,160 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Acknowledgement Checklist Items" %} - PX360{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
|
||||
<!-- Page Header -->
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-2xl shadow-lg shadow-purple-200">
|
||||
<i data-lucide="check-square" class="w-8 h-8 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-navy">
|
||||
{% trans "Checklist Items" %}
|
||||
</h1>
|
||||
<p class="text-slate text-sm">
|
||||
{% trans "Manage acknowledgement checklist items" %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<a href="{% url 'accounts:acknowledgements:ack_dashboard' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white border-2 border-slate-200 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||
{% trans "Back" %}
|
||||
</a>
|
||||
<a href="{% url 'accounts:acknowledgements:ack_checklist_create' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-purple-500 to-indigo-500 text-white rounded-xl font-semibold hover:from-indigo-500 hover:to-purple-500 transition shadow-lg shadow-purple-200">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||
{% trans "New Item" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Section -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-4 mb-6">
|
||||
<form method="get" class="flex flex-wrap gap-4 items-end">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate mb-1">{% trans "Category" %}</label>
|
||||
<select name="category" class="px-4 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-blue focus:border-blue">
|
||||
<option value="">{% trans "All Categories" %}</option>
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat.id }}" {% if request.GET.category == cat.id|stringformat:"s" %}selected{% endif %}>{{ cat.name_en }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate mb-1">{% trans "Status" %}</label>
|
||||
<select name="is_active" class="px-4 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-blue focus:border-blue">
|
||||
<option value="">{% trans "All" %}</option>
|
||||
<option value="true" {% if request.GET.is_active == "true" %}selected{% endif %}>{% trans "Active" %}</option>
|
||||
<option value="false" {% if request.GET.is_active == "false" %}selected{% endif %}>{% trans "Inactive" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-2 bg-blue text-white rounded-xl text-sm font-semibold hover:bg-navy transition">
|
||||
<i data-lucide="filter" class="w-4 h-4 inline mr-1"></i>
|
||||
{% trans "Filter" %}
|
||||
</button>
|
||||
<a href="{% url 'accounts:acknowledgements:ack_checklist_list' %}" class="px-4 py-2 text-slate hover:text-navy text-sm">
|
||||
{% trans "Clear" %}
|
||||
</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Checklist Items List -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
|
||||
<div class="px-6 py-5 border-b border-slate-100 bg-gradient-to-r from-purple-50 to-transparent">
|
||||
<h2 class="text-lg font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="list" class="w-5 h-5 text-purple-500"></i>
|
||||
{% trans "All Checklist Items" %}
|
||||
<span class="ml-2 px-2 py-0.5 bg-slate-100 text-slate text-sm rounded-full">{{ items.count }}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
{% if items %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-200">
|
||||
<th class="text-left py-3 px-4 text-xs font-semibold text-slate uppercase">{% trans "Item" %}</th>
|
||||
<th class="text-left py-3 px-4 text-xs font-semibold text-slate uppercase">{% trans "Category" %}</th>
|
||||
<th class="text-left py-3 px-4 text-xs font-semibold text-slate uppercase">{% trans "Code" %}</th>
|
||||
<th class="text-left py-3 px-4 text-xs font-semibold text-slate uppercase">{% trans "Requires Signature" %}</th>
|
||||
<th class="text-left py-3 px-4 text-xs font-semibold text-slate uppercase">{% trans "Status" %}</th>
|
||||
<th class="text-right py-3 px-4 text-xs font-semibold text-slate uppercase">{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
<tr class="border-b border-slate-100 hover:bg-slate-50 transition">
|
||||
<td class="py-4 px-4">
|
||||
<div>
|
||||
<p class="font-semibold text-navy">{{ item.text_en }}</p>
|
||||
{% if item.text_ar %}
|
||||
<p class="text-sm text-slate">{{ item.text_ar }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-4 px-4">
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-semibold" style="background-color: {{ item.category.color }}20; color: {{ item.category.color }}">
|
||||
{{ item.category.name_en }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-4 px-4">
|
||||
<span class="inline-flex items-center px-2 py-1 bg-slate-100 text-slate-700 rounded-lg text-xs font-mono">
|
||||
{{ item.code|default:"-" }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-4 px-4">
|
||||
{% if item.requires_signature %}
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 bg-blue-100 text-blue-700 rounded-lg text-xs font-semibold">
|
||||
<i data-lucide="pen-tool" class="w-3 h-3"></i>
|
||||
{% trans "Yes" %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-2 py-1 bg-slate-100 text-slate-500 rounded-lg text-xs">
|
||||
{% trans "No" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="py-4 px-4">
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold {% if item.is_active %}bg-emerald-100 text-emerald-700{% else %}bg-red-100 text-red-700{% endif %}">
|
||||
<i data-lucide="{% if item.is_active %}check-circle{% else %}x-circle{% endif %}" class="w-3.5 h-3.5"></i>
|
||||
{% if item.is_active %}{% trans "Active" %}{% else %}{% trans "Inactive" %}{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-4 px-4 text-right">
|
||||
<a href="{% url 'accounts:acknowledgements:ack_checklist_edit' item.id %}" class="inline-flex items-center gap-1 px-3 py-1.5 bg-purple-50 text-purple-700 rounded-lg text-sm font-semibold hover:bg-purple-100 transition">
|
||||
<i data-lucide="edit-2" class="w-4 h-4"></i>
|
||||
{% trans "Edit" %}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-16">
|
||||
<div class="w-20 h-20 bg-gradient-to-br from-purple-100 to-indigo-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i data-lucide="check-square" class="w-10 h-10 text-purple-500"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-navy mb-2">{% trans "No Checklist Items" %}</h3>
|
||||
<p class="text-slate mb-6">{% trans "Create your first acknowledgement checklist item to get started." %}</p>
|
||||
<a href="{% url 'accounts:acknowledgements:ack_checklist_create' %}" class="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-purple-500 to-indigo-500 text-white rounded-xl font-semibold hover:from-indigo-500 hover:to-purple-500 transition shadow-lg shadow-purple-200">
|
||||
<i data-lucide="plus" class="w-5 h-5"></i>
|
||||
{% trans "Create Item" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
196
templates/accounts/acknowledgements/compliance.html
Normal file
196
templates/accounts/acknowledgements/compliance.html
Normal file
@ -0,0 +1,196 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Acknowledgement Compliance Report" %} - PX360{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
|
||||
<!-- Page Header -->
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-amber-500 to-orange-500 rounded-2xl shadow-lg shadow-amber-200">
|
||||
<i data-lucide="pie-chart" class="w-8 h-8 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-navy">
|
||||
{% trans "Compliance Report" %}
|
||||
</h1>
|
||||
<p class="text-slate text-sm">
|
||||
{% trans "Track acknowledgement completion rates" %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<a href="{% url 'accounts:acknowledgements:ack_dashboard' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white border-2 border-slate-200 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||
{% trans "Back" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center">
|
||||
<i data-lucide="users" class="w-6 h-6 text-blue-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate uppercase">{% trans "Total Users" %}</p>
|
||||
<p class="text-2xl font-bold text-navy">{{ stats.total_users }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 bg-emerald-100 rounded-xl flex items-center justify-center">
|
||||
<i data-lucide="check-circle" class="w-6 h-6 text-emerald-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate uppercase">{% trans "Fully Compliant" %}</p>
|
||||
<p class="text-2xl font-bold text-emerald-600">{{ stats.compliant_users }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 bg-amber-100 rounded-xl flex items-center justify-center">
|
||||
<i data-lucide="clock" class="w-6 h-6 text-amber-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate uppercase">{% trans "Pending" %}</p>
|
||||
<p class="text-2xl font-bold text-amber-600">{{ stats.pending_users }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-emerald-500 to-green-500 rounded-xl flex items-center justify-center">
|
||||
<i data-lucide="percent" class="w-6 h-6 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate uppercase">{% trans "Compliance Rate" %}</p>
|
||||
<p class="text-2xl font-bold text-emerald-600">{{ stats.compliance_rate }}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category Breakdown -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden mb-8">
|
||||
<div class="px-6 py-5 border-b border-slate-100 bg-gradient-to-r from-amber-50 to-transparent">
|
||||
<h2 class="text-lg font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="bar-chart-3" class="w-5 h-5 text-amber-500"></i>
|
||||
{% trans "Compliance by Category" %}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
{% if category_stats %}
|
||||
<div class="space-y-4">
|
||||
{% for cat_stat in category_stats %}
|
||||
<div class="p-4 border border-slate-100 rounded-xl">
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center" style="background-color: {{ cat_stat.category.color }}20">
|
||||
<i data-lucide="{{ cat_stat.category.icon|default:'folder' }}" class="w-5 h-5" style="color: {{ cat_stat.category.color }}"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-navy">{{ cat_stat.category.name_en }}</p>
|
||||
<p class="text-xs text-slate">{{ cat_stat.total_items }} {% trans "items" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-2xl font-bold {% widthratio cat_stat.completion_rate 1 100 %}">
|
||||
{{ cat_stat.completion_rate|floatformat:0 }}%
|
||||
</p>
|
||||
<p class="text-xs text-slate">{{ cat_stat.signed }}/{{ cat_stat.total }} {% trans "completed" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full bg-slate-100 rounded-full h-3">
|
||||
<div class="h-3 rounded-full transition-all duration-500" style="width: {{ cat_stat.completion_rate|floatformat:0 }}%; background-color: {{ cat_stat.category.color }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-12">
|
||||
<p class="text-slate">{% trans "No category data available" %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Non-Compliant Users -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
|
||||
<div class="px-6 py-5 border-b border-slate-100 bg-gradient-to-r from-red-50 to-transparent">
|
||||
<h2 class="text-lg font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="alert-triangle" class="w-5 h-5 text-red-500"></i>
|
||||
{% trans "Non-Compliant Users" %}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
{% if non_compliant_users %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-200">
|
||||
<th class="text-left py-3 px-4 text-xs font-semibold text-slate uppercase">{% trans "User" %}</th>
|
||||
<th class="text-left py-3 px-4 text-xs font-semibold text-slate uppercase">{% trans "Department" %}</th>
|
||||
<th class="text-left py-3 px-4 text-xs font-semibold text-slate uppercase">{% trans "Pending Items" %}</th>
|
||||
<th class="text-left py-3 px-4 text-xs font-semibold text-slate uppercase">{% trans "Completion" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user_data in non_compliant_users %}
|
||||
<tr class="border-b border-slate-100 hover:bg-slate-50 transition">
|
||||
<td class="py-4 px-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-blue to-navy rounded-full flex items-center justify-center">
|
||||
<span class="text-white font-semibold text-sm">{{ user_data.user.get_initials }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-navy">{{ user_data.user.get_full_name }}</p>
|
||||
<p class="text-sm text-slate">{{ user_data.user.email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-4 px-4 text-slate">
|
||||
{{ user_data.user.department.name|default:"-" }}
|
||||
</td>
|
||||
<td class="py-4 px-4">
|
||||
<span class="inline-flex items-center px-2.5 py-1 bg-red-100 text-red-700 rounded-lg text-xs font-semibold">
|
||||
{{ user_data.pending_count }} {% trans "pending" %}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-4 px-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-24 bg-slate-100 rounded-full h-2">
|
||||
<div class="bg-amber-500 h-2 rounded-full" style="width: {{ user_data.completion_rate }}%"></div>
|
||||
</div>
|
||||
<span class="text-sm text-slate">{{ user_data.completion_rate }}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-12">
|
||||
<div class="w-16 h-16 bg-emerald-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i data-lucide="check-circle" class="w-8 h-8 text-emerald-500"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-navy mb-2">{% trans "All Users Compliant!" %}</h3>
|
||||
<p class="text-slate">{% trans "All users have completed their required acknowledgements." %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
184
templates/accounts/acknowledgements/dashboard.html
Normal file
184
templates/accounts/acknowledgements/dashboard.html
Normal file
@ -0,0 +1,184 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "My Acknowledgements" %} - PX360{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
|
||||
<!-- Page Header -->
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-blue to-indigo-500 rounded-2xl shadow-lg shadow-blue-200">
|
||||
<i data-lucide="clipboard-check" class="w-8 h-8 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-navy">
|
||||
{% trans "My Acknowledgements" %}
|
||||
</h1>
|
||||
<p class="text-slate text-sm">
|
||||
{% trans "Review and sign required acknowledgements" %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<a href="{% url 'accounts:acknowledgements:ack_signed_list' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white border-2 border-emerald-200 text-emerald-700 rounded-xl font-semibold hover:bg-emerald-50 transition">
|
||||
<i data-lucide="check-circle" class="w-4 h-4"></i>
|
||||
{% trans "Completed" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-blue-100 p-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-blue to-navy rounded-xl flex items-center justify-center">
|
||||
<i data-lucide="list" class="w-6 h-6 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate uppercase">{% trans "Total" %}</p>
|
||||
<p class="text-3xl font-bold text-navy">{{ total_count }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-orange-100 p-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-orange-400 to-orange-500 rounded-xl flex items-center justify-center">
|
||||
<i data-lucide="clock" class="w-6 h-6 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate uppercase">{% trans "Pending" %}</p>
|
||||
<p class="text-3xl font-bold text-orange-600">{{ pending_count }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-emerald-100 p-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-emerald-500 to-green-500 rounded-xl flex items-center justify-center">
|
||||
<i data-lucide="check-circle" class="w-6 h-6 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate uppercase">{% trans "Completed" %}</p>
|
||||
<p class="text-3xl font-bold text-emerald-600">{{ completed_count }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Pending Acknowledgements -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-orange-100 overflow-hidden">
|
||||
<div class="px-6 py-5 border-b border-orange-100 bg-gradient-to-r from-orange-50 to-transparent">
|
||||
<h2 class="text-lg font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="clock" class="w-5 h-5 text-orange-500"></i>
|
||||
{% trans "Pending" %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 bg-orange-100 text-orange-700 rounded-full text-xs font-bold">
|
||||
{{ pending_count }}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
{% if pending_items %}
|
||||
<div class="space-y-4">
|
||||
{% for item in pending_items %}
|
||||
<div class="p-4 border-2 border-orange-100 rounded-xl hover:border-orange-300 hover:shadow-md transition-all duration-200">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="inline-flex items-center px-2 py-1 bg-blue-50 text-blue-700 rounded-lg text-xs font-bold">
|
||||
{{ item.category.name_en }}
|
||||
</span>
|
||||
{% if item.is_required %}
|
||||
<span class="inline-flex items-center px-2 py-1 bg-red-50 text-red-700 rounded-lg text-xs font-bold">
|
||||
{% trans "Required" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h3 class="font-bold text-navy mb-1">{{ item.text_en }}</h3>
|
||||
{% if item.description_en %}
|
||||
<p class="text-sm text-slate line-clamp-2">{{ item.description_en }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<a href="{% url 'accounts:acknowledgements:ack_sign' item.id %}" class="inline-flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-orange-400 to-orange-500 text-white rounded-xl font-semibold hover:from-orange-500 hover:to-orange-600 transition shadow-lg shadow-orange-200 text-sm whitespace-nowrap">
|
||||
{% trans "Sign Now" %}
|
||||
<i data-lucide="arrow-right" class="w-4 h-4"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-12">
|
||||
<div class="w-16 h-16 bg-gradient-to-br from-emerald-100 to-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i data-lucide="check-circle" class="w-8 h-8 text-emerald-500"></i>
|
||||
</div>
|
||||
<p class="text-slate font-medium">{% trans "All acknowledgements completed!" %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Completed Acknowledgements -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-emerald-100 overflow-hidden">
|
||||
<div class="px-6 py-5 border-b border-emerald-100 bg-gradient-to-r from-emerald-50 to-transparent">
|
||||
<h2 class="text-lg font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="check-circle" class="w-5 h-5 text-emerald-500"></i>
|
||||
{% trans "Completed" %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 bg-emerald-100 text-emerald-700 rounded-full text-xs font-bold">
|
||||
{{ completed_count }}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
{% if completed_acknowledgements %}
|
||||
<div class="space-y-4">
|
||||
{% for ack in completed_acknowledgements %}
|
||||
<div class="p-4 border-2 border-emerald-100 rounded-xl bg-emerald-50/30">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="inline-flex items-center px-2 py-1 bg-blue-50 text-blue-700 rounded-lg text-xs font-bold">
|
||||
{{ ack.checklist_item.category.name_en }}
|
||||
</span>
|
||||
<span class="inline-flex items-center px-2 py-1 bg-emerald-100 text-emerald-700 rounded-lg text-xs font-bold">
|
||||
<i data-lucide="check" class="w-3 h-3 mr-1"></i>
|
||||
{% trans "Signed" %}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="font-bold text-navy mb-1">{{ ack.checklist_item.text_en }}</h3>
|
||||
<p class="text-sm text-slate">
|
||||
<i data-lucide="calendar" class="w-3 h-3 inline mr-1"></i>
|
||||
{{ ack.acknowledged_at|date:"M d, Y" }}
|
||||
</p>
|
||||
</div>
|
||||
{% if ack.pdf_file %}
|
||||
<a href="{% url 'accounts:acknowledgements:ack_download_pdf' ack.id %}" class="inline-flex items-center gap-2 px-4 py-2 bg-white border-2 border-blue-200 text-blue-700 rounded-xl font-semibold hover:bg-blue-50 transition text-sm whitespace-nowrap">
|
||||
<i data-lucide="download" class="w-4 h-4"></i>
|
||||
{% trans "PDF" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-12">
|
||||
<div class="w-16 h-16 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i data-lucide="clipboard-list" class="w-8 h-8 text-blue-500"></i>
|
||||
</div>
|
||||
<p class="text-slate font-medium">{% trans "No completed acknowledgements yet" %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
98
templates/accounts/acknowledgements/sign.html
Normal file
98
templates/accounts/acknowledgements/sign.html
Normal file
@ -0,0 +1,98 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{{ checklist_item.text_en }} - {% trans "Sign Acknowledgement" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<!-- Back Button -->
|
||||
<a href="{% url 'accounts:acknowledgements:ack_dashboard' %}" class="inline-flex items-center gap-2 text-blue hover:text-navy mb-6 font-medium">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||
{% trans "Back to Dashboard" %}
|
||||
</a>
|
||||
|
||||
<!-- Acknowledgement Card -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-blue-100 overflow-hidden">
|
||||
<div class="px-6 py-5 border-b border-blue-100 bg-gradient-to-r from-blue-50 to-transparent">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span class="inline-flex items-center px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-xs font-bold">
|
||||
{{ checklist_item.category.name_en }}
|
||||
</span>
|
||||
{% if checklist_item.is_required %}
|
||||
<span class="inline-flex items-center px-3 py-1 bg-red-100 text-red-700 rounded-full text-xs font-bold">
|
||||
{% trans "Required" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-navy">{{ checklist_item.text_en }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<!-- Content -->
|
||||
{% if checklist_item.description_en %}
|
||||
<div class="mb-6 p-4 bg-blue-50 rounded-xl border border-blue-100">
|
||||
<h3 class="font-bold text-navy mb-2">{% trans "Important Information" %}</h3>
|
||||
<p class="text-slate">{{ checklist_item.description_en }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Acknowledgement Text -->
|
||||
<div class="mb-6">
|
||||
<h3 class="font-bold text-navy mb-3">{% trans "Acknowledgement Statement" %}</h3>
|
||||
<p class="text-slate leading-relaxed">{{ checklist_item.text_en }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Signature Form -->
|
||||
<form method="post" class="space-y-6">
|
||||
{% csrf_token %}
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-navy mb-2">
|
||||
{% trans "Your Signature" %} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="signature" required
|
||||
class="w-full px-4 py-3 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition"
|
||||
placeholder="{% trans 'Type your full name as signature' %}"
|
||||
value="{{ request.user.get_full_name|default:request.user.email }}">
|
||||
<p class="text-xs text-slate mt-2">
|
||||
{% trans "By typing your name above, you acknowledge that you have read and understood this statement." %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Info Box -->
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<i data-lucide="info" class="w-5 h-5 text-amber-600 mt-0.5 flex-shrink-0"></i>
|
||||
<div>
|
||||
<p class="text-sm font-bold text-amber-800 mb-1">{% trans "Legal Notice" %}</p>
|
||||
<p class="text-sm text-amber-700">
|
||||
{% trans "This acknowledgement is legally binding. A PDF copy will be generated and stored in your records." %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Buttons -->
|
||||
<div class="flex gap-3 pt-6 border-t border-blue-100">
|
||||
<button type="submit" class="flex-1 inline-flex items-center justify-center gap-2 px-6 py-3 bg-gradient-to-r from-blue to-navy text-white rounded-xl font-semibold hover:from-navy hover:to-blue transition shadow-lg shadow-blue-200">
|
||||
<i data-lucide="check-circle" class="w-5 h-5"></i>
|
||||
{% trans "I Acknowledge and Sign" %}
|
||||
</button>
|
||||
<a href="{% url 'accounts:acknowledgements:ack_dashboard' %}" class="inline-flex items-center justify-center gap-2 px-6 py-3 bg-white border-2 border-slate-200 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition">
|
||||
<i data-lucide="x" class="w-5 h-5"></i>
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
133
templates/accounts/acknowledgements/signed_list.html
Normal file
133
templates/accounts/acknowledgements/signed_list.html
Normal file
@ -0,0 +1,133 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Completed Acknowledgements" %} - PX360{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
|
||||
<!-- Page Header -->
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-emerald-500 to-green-500 rounded-2xl shadow-lg shadow-emerald-200">
|
||||
<i data-lucide="check-circle" class="w-8 h-8 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-navy">
|
||||
{% trans "Completed Acknowledgements" %}
|
||||
</h1>
|
||||
<p class="text-slate text-sm">
|
||||
{% trans "View your signed acknowledgements" %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<a href="{% url 'accounts:acknowledgements:ack_dashboard' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white border-2 border-blue-200 text-blue-700 rounded-xl font-semibold hover:bg-blue-50 transition">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||
{% trans "Back to Dashboard" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Card -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-emerald-100 p-6 mb-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-emerald-500 to-green-500 rounded-xl flex items-center justify-center">
|
||||
<i data-lucide="file-check" class="w-6 h-6 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate uppercase">{% trans "Total Signed" %}</p>
|
||||
<p class="text-3xl font-bold text-emerald-600">{{ acknowledgements.count }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Acknowledgements List -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
|
||||
<div class="px-6 py-5 border-b border-slate-100 bg-gradient-to-r from-emerald-50 to-transparent">
|
||||
<h2 class="text-lg font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="clipboard-check" class="w-5 h-5 text-emerald-500"></i>
|
||||
{% trans "Signed Documents" %}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
{% if acknowledgements %}
|
||||
<div class="space-y-4">
|
||||
{% for ack in acknowledgements %}
|
||||
<div class="p-5 border-2 border-emerald-100 rounded-xl bg-emerald-50/30 hover:border-emerald-300 hover:shadow-md transition-all duration-200">
|
||||
<div class="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-3">
|
||||
<span class="inline-flex items-center px-2.5 py-1 bg-blue-50 text-blue-700 rounded-lg text-xs font-bold">
|
||||
{{ ack.checklist_item.category.name_en }}
|
||||
</span>
|
||||
<span class="inline-flex items-center px-2.5 py-1 bg-emerald-100 text-emerald-700 rounded-lg text-xs font-bold">
|
||||
<i data-lucide="check" class="w-3 h-3 mr-1"></i>
|
||||
{% trans "Signed" %}
|
||||
</span>
|
||||
{% if ack.checklist_item.code %}
|
||||
<span class="inline-flex items-center px-2 py-1 bg-slate-100 text-slate-600 rounded-lg text-xs font-mono">
|
||||
{{ ack.checklist_item.code }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h3 class="font-bold text-navy text-lg mb-2">{{ ack.checklist_item.text_en }}</h3>
|
||||
{% if ack.checklist_item.description_en %}
|
||||
<p class="text-sm text-slate mb-3">{{ ack.checklist_item.description_en }}</p>
|
||||
{% endif %}
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm text-slate">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i data-lucide="calendar" class="w-4 h-4"></i>
|
||||
<span>{% trans "Signed on" %}: {{ ack.acknowledged_at|date:"F d, Y" }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i data-lucide="clock" class="w-4 h-4"></i>
|
||||
<span>{{ ack.acknowledged_at|time:"H:i" }}</span>
|
||||
</div>
|
||||
{% if ack.signature %}
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i data-lucide="pen-tool" class="w-4 h-4"></i>
|
||||
<span>{% trans "Signature" %}: {{ ack.signature }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{% if ack.pdf_file %}
|
||||
<a href="{% url 'accounts:acknowledgements:ack_download_pdf' ack.id %}" class="inline-flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue to-navy text-white rounded-xl font-semibold hover:from-navy hover:to-blue transition shadow-lg shadow-blue-200 text-sm whitespace-nowrap">
|
||||
<i data-lucide="download" class="w-4 h-4"></i>
|
||||
{% trans "Download PDF" %}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center gap-2 px-4 py-2.5 bg-slate-100 text-slate-500 rounded-xl text-sm whitespace-nowrap">
|
||||
<i data-lucide="file-x" class="w-4 h-4"></i>
|
||||
{% trans "PDF not available" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-16">
|
||||
<div class="w-20 h-20 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i data-lucide="clipboard-list" class="w-10 h-10 text-blue-500"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-navy mb-2">{% trans "No Completed Acknowledgements" %}</h3>
|
||||
<p class="text-slate mb-6">{% trans "You haven't signed any acknowledgements yet." %}</p>
|
||||
<a href="{% url 'accounts:acknowledgements:ack_dashboard' %}" class="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-blue to-navy text-white rounded-xl font-semibold hover:from-navy hover:to-blue transition shadow-lg shadow-blue-200">
|
||||
<i data-lucide="clipboard-check" class="w-5 h-5"></i>
|
||||
{% trans "View Pending Acknowledgements" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -4,66 +4,84 @@
|
||||
{% block title %}{% trans "Bulk Invite" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-6 md:p-8">
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<a href="{% url 'accounts:onboarding_dashboard' %}" class="inline-flex items-center text-navy hover:text-navy mb-4 font-medium">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i>
|
||||
{% trans "Back to Dashboard" %}
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold text-gray-800 mb-2">
|
||||
{% trans "Send Bulk Invitations" %}
|
||||
</h1>
|
||||
<p class="text-gray-500">
|
||||
{% trans "Invite multiple staff members to complete onboarding" %}
|
||||
</p>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-blue to-navy rounded-2xl shadow-lg shadow-blue-200">
|
||||
<i data-lucide="mail-plus" class="w-8 h-8 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{% url 'accounts:acknowledgement-dashboard' %}" class="inline-flex items-center text-blue hover:text-navy mb-2 font-medium">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i>
|
||||
{% trans "Back to Dashboard" %}
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold text-navy">
|
||||
{% trans "Send Bulk Invitations" %}
|
||||
</h1>
|
||||
<p class="text-slate">
|
||||
{% trans "Invite multiple staff members to complete onboarding" %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<!-- Form -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 md:p-8">
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-blue-100 p-6 md:p-8">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="block text-gray-700 font-medium mb-2">
|
||||
|
||||
<div class="mb-8">
|
||||
<label class="block text-navy font-bold mb-3">
|
||||
{% trans "Upload CSV File" %}
|
||||
</label>
|
||||
<div class="border-2 border-dashed border-gray-200 rounded-2xl p-8 text-center hover:border-blue transition cursor-pointer">
|
||||
<div class="border-2 border-dashed border-blue-200 rounded-2xl p-8 text-center hover:border-blue transition cursor-pointer bg-blue-50/50">
|
||||
<input type="file" name="csv_file" accept=".csv" class="hidden" id="csv_file">
|
||||
<label for="csv_file" class="cursor-pointer">
|
||||
<i data-lucide="upload-cloud" class="w-12 h-12 text-gray-400 mx-auto mb-3"></i>
|
||||
<p class="text-gray-600 font-medium mb-1">{% trans "Click to upload CSV file" %}</p>
|
||||
<p class="text-gray-400 text-sm">{% trans "or drag and drop" %}</p>
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-16 h-16 bg-gradient-to-br from-blue to-navy rounded-2xl flex items-center justify-center mb-4 mx-auto">
|
||||
<i data-lucide="upload-cloud" class="w-8 h-8 text-white"></i>
|
||||
</div>
|
||||
<p class="text-navy font-bold mb-1">{% trans "Click to upload CSV file" %}</p>
|
||||
<p class="text-slate text-sm">{% trans "or drag and drop" %}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400 mt-2">
|
||||
<i data-lucide="info" class="w-4 h-4 inline mr-1"></i>
|
||||
{% trans "CSV should contain columns: email, name, department" %}
|
||||
</p>
|
||||
<div class="mt-4 bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<i data-lucide="info" class="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0"></i>
|
||||
<div>
|
||||
<p class="text-sm font-bold text-navy mb-1">{% trans "CSV Format Requirements" %}</p>
|
||||
<p class="text-xs text-slate">{% trans "Required columns: email, first_name, last_name, role" %}</p>
|
||||
<p class="text-xs text-slate">{% trans "Optional columns: hospital_id, department" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="block text-gray-700 font-medium mb-2">
|
||||
<div class="mb-8">
|
||||
<label class="block text-navy font-bold mb-3">
|
||||
{% trans "Or Enter Email Addresses" %}
|
||||
</label>
|
||||
<textarea name="emails" rows="5" class="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent" placeholder="email1@example.com email2@example.com email3@example.com"></textarea>
|
||||
<textarea name="emails" rows="5" class="w-full px-4 py-3 border-2 border-blue-100 rounded-xl text-navy focus:outline-none focus:ring-2 focus:ring-blue focus:border-transparent transition" placeholder="email1@example.com email2@example.com email3@example.com"></textarea>
|
||||
<p class="text-xs text-slate mt-2">{% trans "Enter one email address per line" %}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="block text-gray-700 font-medium mb-2">
|
||||
<div class="mb-8">
|
||||
<label class="block text-navy font-bold mb-3">
|
||||
{% trans "Custom Message (Optional)" %}
|
||||
</label>
|
||||
<textarea name="custom_message" rows="3" class="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent" placeholder="{% trans 'Add a personal note to your invitation emails...' %}"></textarea>
|
||||
<textarea name="custom_message" rows="3" class="w-full px-4 py-3 border-2 border-blue-100 rounded-xl text-navy focus:outline-none focus:ring-2 focus:ring-blue focus:border-transparent transition" placeholder="{% trans 'Add a personal note to your invitation emails...' %}"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<button type="submit" class="flex-1 bg-gradient-to-r from-navy to-orange-500 text-white px-6 py-3 rounded-xl font-bold hover:from-navy hover:to-orange-600 transition shadow-lg shadow-blue-200">
|
||||
<i data-lucide="send" class="w-5 h-5 inline mr-2"></i>
|
||||
<button type="submit" class="flex-1 bg-gradient-to-r from-blue to-navy text-white px-6 py-4 rounded-xl font-bold hover:from-navy hover:to-blue transition shadow-lg shadow-blue-200 flex items-center justify-center gap-2">
|
||||
<i data-lucide="send" class="w-5 h-5"></i>
|
||||
{% trans "Send Invitations" %}
|
||||
</button>
|
||||
<a href="{% url 'accounts:onboarding_dashboard' %}" class="px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-bold hover:bg-gray-200 transition">
|
||||
<a href="{% url 'accounts:acknowledgement-dashboard' %}" class="px-6 py-4 bg-slate-100 text-slate-700 rounded-xl font-bold hover:bg-slate-200 transition">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
</div>
|
||||
@ -73,36 +91,60 @@
|
||||
|
||||
<!-- Help Sidebar -->
|
||||
<div class="space-y-6">
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-2xl p-6">
|
||||
<h3 class="font-bold text-blue-800 mb-3 flex items-center gap-2">
|
||||
<i data-lucide="help-circle" class="w-5 h-5"></i>
|
||||
<div class="bg-gradient-to-br from-blue-50 to-indigo-50 border border-blue-200 rounded-2xl p-6">
|
||||
<h3 class="font-bold text-navy mb-4 flex items-center gap-2">
|
||||
<i data-lucide="help-circle" class="w-5 h-5 text-blue"></i>
|
||||
{% trans "How it works" %}
|
||||
</h3>
|
||||
<ol class="text-blue-700 text-sm space-y-2 list-decimal list-inside">
|
||||
<li>{% trans "Upload a CSV file or enter emails manually" %}</li>
|
||||
<li>{% trans "Preview and verify the invitation list" %}</li>
|
||||
<li>{% trans "Send invitations to all staff members" %}</li>
|
||||
<li>{% trans "Track their onboarding progress" %}</li>
|
||||
</ol>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex items-center justify-center w-8 h-8 bg-gradient-to-br from-blue to-navy rounded-lg flex-shrink-0">
|
||||
<span class="text-white font-bold text-sm">1</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-bold text-navy">{% trans "Prepare CSV or emails" %}</p>
|
||||
<p class="text-xs text-slate">{% trans "Upload a CSV file or enter email addresses" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex items-center justify-center w-8 h-8 bg-gradient-to-br from-blue to-navy rounded-lg flex-shrink-0">
|
||||
<span class="text-white font-bold text-sm">2</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-bold text-navy">{% trans "Review & send" %}</p>
|
||||
<p class="text-xs text-slate">{% trans "We'll send personalized invitations" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex items-center justify-center w-8 h-8 bg-gradient-to-br from-blue to-navy rounded-lg flex-shrink-0">
|
||||
<span class="text-white font-bold text-sm">3</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-bold text-navy">{% trans "Track progress" %}</p>
|
||||
<p class="text-xs text-slate">{% trans "Monitor completion from the dashboard" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-light border border-blue-200 rounded-2xl p-6">
|
||||
<h3 class="font-bold text-navy mb-3 flex items-center gap-2">
|
||||
<i data-lucide="alert-triangle" class="w-5 h-5"></i>
|
||||
{% trans "Important Notes" %}
|
||||
</h3>
|
||||
<ul class="text-navy text-sm space-y-2">
|
||||
<li class="flex gap-2">
|
||||
<i data-lucide="check" class="w-4 h-4 flex-shrink-0 mt-0.5"></i>
|
||||
<span>{% trans "Each email will receive a unique activation link" %}</span>
|
||||
<div class="bg-white border border-blue-100 rounded-2xl p-6">
|
||||
<h4 class="font-bold text-navy mb-3">{% trans "Quick Tips" %}</h4>
|
||||
<ul class="space-y-2 text-sm text-slate">
|
||||
<li class="flex items-start gap-2">
|
||||
<i data-lucide="check" class="w-4 h-4 text-emerald-500 mt-0.5 flex-shrink-0"></i>
|
||||
<span>{% trans "Use CSV for large batches (50+ users)" %}</span>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<i data-lucide="check" class="w-4 h-4 flex-shrink-0 mt-0.5"></i>
|
||||
<span>{% trans "Links expire after 7 days" %}</span>
|
||||
<li class="flex items-start gap-2">
|
||||
<i data-lucide="check" class="w-4 h-4 text-emerald-500 mt-0.5 flex-shrink-0"></i>
|
||||
<span>{% trans "Manual entry works well for small groups" %}</span>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<i data-lucide="check" class="w-4 h-4 flex-shrink-0 mt-0.5"></i>
|
||||
<span>{% trans "Staff must complete onboarding to access the system" %}</span>
|
||||
<li class="flex items-start gap-2">
|
||||
<i data-lucide="check" class="w-4 h-4 text-emerald-500 mt-0.5 flex-shrink-0"></i>
|
||||
<span>{% trans "Add a custom message for personalization" %}</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i data-lucide="check" class="w-4 h-4 text-emerald-500 mt-0.5 flex-shrink-0"></i>
|
||||
<span>{% trans "Double-check email addresses before sending" %}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -115,4 +157,4 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@ -4,62 +4,72 @@
|
||||
{% block title %}{% trans "Acknowledgement Checklist Items" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
|
||||
<!-- Page Header -->
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold text-gray-800 mb-2 flex items-center gap-2">
|
||||
<i data-lucide="list-checks" class="w-8 h-8 text-navy"></i>
|
||||
{% trans "Checklist Items Management" %}
|
||||
</h2>
|
||||
<p class="text-gray-500">{% trans "Manage acknowledgement checklist items" %}</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-blue to-navy rounded-2xl shadow-lg shadow-blue-200">
|
||||
<i data-lucide="list-checks" class="w-8 h-8 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold text-navy mb-1">
|
||||
{% trans "Checklist Items" %}
|
||||
</h2>
|
||||
<p class="text-slate">{% trans "Manage acknowledgement checklist items" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="bg-light0 text-white px-6 py-3 rounded-xl font-bold hover:bg-navy transition flex items-center gap-2 shadow-lg shadow-blue-200" onclick="document.getElementById('createChecklistItemModal').classList.remove('hidden')">
|
||||
<button type="button" onclick="document.getElementById('createChecklistItemModal').classList.remove('hidden')"
|
||||
class="bg-gradient-to-r from-blue to-navy text-white px-6 py-3 rounded-xl font-bold hover:from-navy hover:to-blue transition shadow-lg shadow-blue-200 flex items-center gap-2">
|
||||
<i data-lucide="plus" class="w-5 h-5"></i>
|
||||
{% trans "Add Checklist Item" %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Checklist Items List -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-50 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-100 flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<h3 class="font-bold text-gray-800 flex items-center gap-2">
|
||||
<i data-lucide="list" class="w-5 h-5 text-navy"></i>
|
||||
{% trans "Checklist Items" %}
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-blue-100 overflow-hidden">
|
||||
<div class="px-6 py-5 border-b border-blue-100 bg-gradient-to-r from-blue-50 to-transparent flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<h3 class="font-bold text-navy flex items-center gap-2 text-lg">
|
||||
<i data-lucide="clipboard-list" class="w-5 h-5 text-blue"></i>
|
||||
{% trans "All Items" %}
|
||||
</h3>
|
||||
<div class="flex items-center gap-2 w-full md:w-auto">
|
||||
<input type="text" id="searchInput" placeholder="{% trans 'Search items...' %}"
|
||||
class="flex-1 md:w-64 px-4 py-2 border-2 border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-navy focus:border-transparent transition">
|
||||
<button class="px-4 py-2 text-gray-600 bg-gray-100 rounded-xl hover:bg-gray-200 transition">
|
||||
<i data-lucide="search" class="w-4 h-4"></i>
|
||||
</button>
|
||||
<div class="relative flex-1 md:w-64">
|
||||
<input type="text" id="searchInput" placeholder="{% trans 'Search items...' %}"
|
||||
class="w-full pl-10 pr-4 py-2.5 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition"
|
||||
value="{{ search_query|default:'' }}">
|
||||
<i data-lucide="search" class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full" id="itemsTable">
|
||||
<thead class="bg-gray-50">
|
||||
<thead class="bg-blue-50">
|
||||
<tr>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Item Text" %}</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Role" %}</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Linked Content" %}</th>
|
||||
<th class="px-6 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Required" %}</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Order" %}</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Status" %}</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Created" %}</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Actions" %}</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Item Text" %}</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Role" %}</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Linked Content" %}</th>
|
||||
<th class="px-6 py-4 text-center text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Required" %}</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Order" %}</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Status" %}</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Created" %}</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<tbody class="divide-y divide-blue-50">
|
||||
{% for item in checklist_items %}
|
||||
<tr class="hover:bg-gray-50 transition">
|
||||
<tr class="hover:bg-blue-50/50 transition">
|
||||
<td class="px-6 py-4">
|
||||
<strong class="text-gray-800">{{ item.text_en }}</strong>
|
||||
{% if item.code %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-gray-100 text-gray-600 ml-2">{{ item.code }}</span>
|
||||
{% endif %}
|
||||
{% if item.description_en %}
|
||||
<p class="text-sm text-gray-500 mt-1">{{ item.description_en }}</p>
|
||||
{% endif %}
|
||||
<div class="flex items-start gap-2">
|
||||
<div>
|
||||
<strong class="text-navy font-bold">{{ item.text_en }}</strong>
|
||||
{% if item.code %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-blue-100 text-blue-700 ml-2">{{ item.code }}</span>
|
||||
{% endif %}
|
||||
{% if item.description_en %}
|
||||
<p class="text-sm text-slate mt-1">{{ item.description_en|truncatewords:15 }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{% if item.role %}
|
||||
@ -67,16 +77,16 @@
|
||||
{{ item.get_role_display }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-gray-100 text-gray-700">{% trans "All Roles" %}</span>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-slate-100 text-slate-700">{% trans "All Roles" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{% if item.content %}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-gray-100 text-gray-700">
|
||||
{{ item.content.title_en }}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-indigo-100 text-indigo-700">
|
||||
{{ item.content.title_en|truncatechars:25 }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400">-</span>
|
||||
<span class="text-slate">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
@ -86,10 +96,10 @@
|
||||
{% trans "Yes" %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-gray-100 text-gray-700">{% trans "No" %}</span>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-slate-100 text-slate-700">{% trans "No" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-gray-800">{{ item.order }}</td>
|
||||
<td class="px-6 py-4 text-navy font-semibold">{{ item.order }}</td>
|
||||
<td class="px-6 py-4">
|
||||
{% if item.is_active %}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-emerald-100 text-emerald-700">
|
||||
@ -97,21 +107,21 @@
|
||||
{% trans "Active" %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-gray-100 text-gray-700">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-slate-100 text-slate-700">
|
||||
<i data-lucide="x" class="w-3 h-3 mr-1"></i>
|
||||
{% trans "Inactive" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-gray-500 text-sm">
|
||||
<td class="px-6 py-4 text-slate text-sm">
|
||||
{{ item.created_at|date:"M d, Y" }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex gap-2">
|
||||
<button class="px-3 py-2 text-blue-600 bg-blue-50 rounded-lg hover:bg-blue-100 transition font-medium text-sm" title="{% trans 'Edit' %}">
|
||||
<button type="button" onclick="editChecklistItem('{{ item.pk }}')" class="px-3 py-2 text-blue-600 bg-blue-50 rounded-lg hover:bg-blue-100 transition font-medium text-sm" title="{% trans 'Edit' %}">
|
||||
<i data-lucide="pencil" class="w-4 h-4"></i>
|
||||
</button>
|
||||
<button class="px-3 py-2 text-red-600 bg-red-50 rounded-lg hover:bg-red-100 transition font-medium text-sm" title="{% trans 'Delete' %}">
|
||||
<button type="button" onclick="deleteChecklistItem('{{ item.pk }}')" class="px-3 py-2 text-red-600 bg-red-50 rounded-lg hover:bg-red-100 transition font-medium text-sm" title="{% trans 'Delete' %}">
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
@ -120,8 +130,14 @@
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-center py-12">
|
||||
<i data-lucide="clipboard-data" class="w-16 h-16 text-gray-300 mx-auto mb-4"></i>
|
||||
<p class="text-gray-500">{% trans "No checklist items found" %}</p>
|
||||
<div class="flex flex-col items-center">
|
||||
<i data-lucide="clipboard-data" class="w-16 h-16 text-blue-200 mx-auto mb-4"></i>
|
||||
<p class="text-slate font-medium">{% trans "No checklist items found" %}</p>
|
||||
<button type="button" onclick="document.getElementById('createChecklistItemModal').classList.remove('hidden')"
|
||||
class="mt-4 text-blue font-semibold hover:underline">
|
||||
{% trans "Add your first checklist item" %}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@ -132,40 +148,40 @@
|
||||
</div>
|
||||
|
||||
<!-- Create Checklist Item Modal -->
|
||||
<div id="createChecklistItemModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-2xl shadow-xl w-full max-w-4xl max-h-[90vh] overflow-y-auto m-4">
|
||||
<div class="px-6 py-4 border-b border-gray-100 flex justify-between items-center">
|
||||
<h3 class="font-bold text-gray-800 flex items-center gap-2">
|
||||
<i data-lucide="plus-circle" class="w-5 h-5 text-navy"></i>
|
||||
<div id="createChecklistItemModal" class="fixed inset-0 bg-black/50 backdrop-blur-sm hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto m-4">
|
||||
<div class="px-6 py-5 border-b border-blue-100 bg-gradient-to-r from-blue-50 to-transparent flex justify-between items-center sticky top-0 rounded-t-2xl">
|
||||
<h3 class="font-bold text-navy flex items-center gap-2 text-lg">
|
||||
<i data-lucide="plus-circle" class="w-5 h-5 text-blue"></i>
|
||||
{% trans "Add New Checklist Item" %}
|
||||
</h3>
|
||||
<button type="button" onclick="document.getElementById('createChecklistItemModal').classList.add('hidden')" class="text-gray-400 hover:text-gray-600 transition">
|
||||
<button type="button" onclick="document.getElementById('createChecklistItemModal').classList.add('hidden')" class="text-slate hover:text-navy transition">
|
||||
<i data-lucide="x" class="w-6 h-6"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form id="createChecklistItemForm">
|
||||
{% csrf_token %}
|
||||
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Code -->
|
||||
<div>
|
||||
<label for="code" class="block text-sm font-bold text-gray-700 mb-2">
|
||||
<i data-lucide="tag" class="w-4 h-4 inline mr-1"></i>
|
||||
<label for="code" class="block text-sm font-bold text-navy mb-2">
|
||||
<i data-lucide="tag" class="w-4 h-4 inline mr-1 text-blue"></i>
|
||||
{% trans "Code" %} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" id="code" name="code" required
|
||||
class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-navy focus:border-transparent transition"
|
||||
placeholder="{% trans 'Unique identifier for this item (e.g., CLINIC_P1)' %}">
|
||||
class="w-full px-4 py-2.5 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition"
|
||||
placeholder="{% trans 'CLINIC_P1' %}">
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Role -->
|
||||
<div>
|
||||
<label for="role" class="block text-sm font-bold text-gray-700 mb-2">
|
||||
<i data-lucide="user-cog" class="w-4 h-4 inline mr-1"></i>
|
||||
<label for="role" class="block text-sm font-bold text-navy mb-2">
|
||||
<i data-lucide="user-cog" class="w-4 h-4 inline mr-1 text-blue"></i>
|
||||
{% trans "Role" %}
|
||||
</label>
|
||||
<select id="role" name="role" class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-navy focus:border-transparent transition">
|
||||
<select id="role" name="role" class="w-full px-4 py-2.5 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition">
|
||||
<option value="">{% trans "All Roles" %}</option>
|
||||
<option value="px_admin">{% trans "PX Admin" %}</option>
|
||||
<option value="hospital_admin">{% trans "Hospital Admin" %}</option>
|
||||
@ -176,105 +192,97 @@
|
||||
<option value="staff">{% trans "Staff" %}</option>
|
||||
<option value="viewer">{% trans "Viewer" %}</option>
|
||||
</select>
|
||||
<p class="text-sm text-gray-400 mt-1">{% trans "Leave empty to apply to all roles" %}</p>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Linked Content -->
|
||||
<div>
|
||||
<label for="content" class="block text-sm font-bold text-gray-700 mb-2">
|
||||
<i data-lucide="file-text" class="w-4 h-4 inline mr-1"></i>
|
||||
<label for="content" class="block text-sm font-bold text-navy mb-2">
|
||||
<i data-lucide="link" class="w-4 h-4 inline mr-1 text-blue"></i>
|
||||
{% trans "Linked Content" %}
|
||||
</label>
|
||||
<select id="content" name="content" class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-navy focus:border-transparent transition">
|
||||
<select id="content" name="content" class="w-full px-4 py-2.5 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition">
|
||||
<option value="">{% trans "No linked content" %}</option>
|
||||
{% for content_item in content_list %}
|
||||
<option value="{{ content_item.id }}">{{ content_item.title_en }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="text-sm text-gray-400 mt-1">{% trans "Optional content section to link with this item" %}</p>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Order -->
|
||||
<div>
|
||||
<label for="order" class="block text-sm font-bold text-gray-700 mb-2">
|
||||
<i data-lucide="arrow-down-1-0" class="w-4 h-4 inline mr-1"></i>
|
||||
<label for="order" class="block text-sm font-bold text-navy mb-2">
|
||||
<i data-lucide="arrow-down-1-0" class="w-4 h-4 inline mr-1 text-blue"></i>
|
||||
{% trans "Display Order" %}
|
||||
</label>
|
||||
<input type="number" id="order" name="order" value="0" min="0"
|
||||
class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-navy focus:border-transparent transition">
|
||||
<p class="text-sm text-gray-400 mt-1">{% trans "Order in which this item appears (lower = first)" %}</p>
|
||||
class="w-full px-4 py-2.5 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition">
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Text (English) -->
|
||||
<div class="md:col-span-2">
|
||||
<label for="text_en" class="block text-sm font-bold text-gray-700 mb-2">
|
||||
<i data-lucide="type" class="w-4 h-4 inline mr-1"></i>
|
||||
<label for="text_en" class="block text-sm font-bold text-navy mb-2">
|
||||
<i data-lucide="type" class="w-4 h-4 inline mr-1 text-blue"></i>
|
||||
{% trans "Text (English)" %} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" id="text_en" name="text_en" required
|
||||
class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-navy focus:border-transparent transition"
|
||||
class="w-full px-4 py-2.5 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition"
|
||||
placeholder="{% trans 'Main text for the checklist item' %}">
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Text (Arabic) -->
|
||||
<div class="md:col-span-2">
|
||||
<label for="text_ar" class="block text-sm font-bold text-gray-700 mb-2">
|
||||
<i data-lucide="type" class="w-4 h-4 inline mr-1"></i>
|
||||
<label for="text_ar" class="block text-sm font-bold text-navy mb-2">
|
||||
<i data-lucide="type" class="w-4 h-4 inline mr-1 text-blue"></i>
|
||||
{% trans "Text (Arabic)" %}
|
||||
</label>
|
||||
<input type="text" id="text_ar" name="text_ar" dir="rtl"
|
||||
class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-navy focus:border-transparent transition"
|
||||
placeholder="{% trans 'Arabic translation (optional)' %}">
|
||||
class="w-full px-4 py-2.5 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition"
|
||||
placeholder="{% trans 'Arabic translation' %}">
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Description (English) -->
|
||||
<div class="md:col-span-2">
|
||||
<label for="description_en" class="block text-sm font-bold text-gray-700 mb-2">
|
||||
<i data-lucide="file-text" class="w-4 h-4 inline mr-1"></i>
|
||||
<label for="description_en" class="block text-sm font-bold text-navy mb-2">
|
||||
<i data-lucide="file-text" class="w-4 h-4 inline mr-1 text-blue"></i>
|
||||
{% trans "Description (English)" %}
|
||||
</label>
|
||||
<textarea id="description_en" name="description_en" rows="3"
|
||||
class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-navy focus:border-transparent transition"
|
||||
placeholder="{% trans 'Additional details (optional)' %}"></textarea>
|
||||
class="w-full px-4 py-2.5 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition"
|
||||
placeholder="{% trans 'Additional details' %}"></textarea>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Description (Arabic) -->
|
||||
<div class="md:col-span-2">
|
||||
<label for="description_ar" class="block text-sm font-bold text-gray-700 mb-2">
|
||||
<i data-lucide="file-text" class="w-4 h-4 inline mr-1"></i>
|
||||
<label for="description_ar" class="block text-sm font-bold text-navy mb-2">
|
||||
<i data-lucide="file-text" class="w-4 h-4 inline mr-1 text-blue"></i>
|
||||
{% trans "Description (Arabic)" %}
|
||||
</label>
|
||||
<textarea id="description_ar" name="description_ar" rows="3" dir="rtl"
|
||||
class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-navy focus:border-transparent transition"
|
||||
placeholder="{% trans 'Arabic translation (optional)' %}"></textarea>
|
||||
class="w-full px-4 py-2.5 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition"
|
||||
placeholder="{% trans 'Arabic translation' %}"></textarea>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Configuration -->
|
||||
<div>
|
||||
<label for="is_required" class="block text-sm font-bold text-gray-700 mb-2">
|
||||
<i data-lucide="check-circle" class="w-4 h-4 inline mr-1"></i>
|
||||
{% trans "Required" %}
|
||||
<label class="block text-sm font-bold text-navy mb-2">
|
||||
<i data-lucide="check-circle" class="w-4 h-4 inline mr-1 text-blue"></i>
|
||||
{% trans "Configuration" %}
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="checkbox" id="is_required" name="is_required" checked
|
||||
class="w-5 h-5 text-navy border-gray-300 rounded focus:ring-2 focus:ring-navy">
|
||||
<span class="text-gray-700">{% trans "Item must be acknowledged" %}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="is_active" class="block text-sm font-bold text-gray-700 mb-2">
|
||||
<i data-lucide="toggle-left" class="w-4 h-4 inline mr-1"></i>
|
||||
{% trans "Active" %}
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="checkbox" id="is_active" name="is_active" checked
|
||||
class="w-5 h-5 text-navy border-gray-300 rounded focus:ring-2 focus:ring-navy">
|
||||
<span class="text-gray-700">{% trans "Item is visible" %}</span>
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" id="is_required" name="is_required" checked
|
||||
class="w-5 h-5 text-blue border-blue-200 rounded focus:ring-2 focus:ring-blue">
|
||||
<span class="text-navy font-medium">{% trans "Item must be acknowledged" %}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" id="is_active" name="is_active" checked
|
||||
class="w-5 h-5 text-blue border-blue-200 rounded focus:ring-2 focus:ring-blue">
|
||||
<span class="text-navy font-medium">{% trans "Item is visible" %}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Error Alert -->
|
||||
<div class="bg-red-50 border border-red-200 rounded-xl p-4 mt-6 hidden" id="formError">
|
||||
<div class="flex items-center gap-2 text-red-700">
|
||||
@ -284,14 +292,14 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t border-gray-100 flex justify-end gap-3">
|
||||
<button type="button" onclick="document.getElementById('createChecklistItemModal').classList.add('hidden')"
|
||||
class="px-6 py-2.5 border-2 border-gray-300 text-gray-700 rounded-xl font-bold hover:bg-gray-50 transition flex items-center gap-2">
|
||||
<div class="px-6 py-4 border-t border-blue-100 bg-gradient-to-r from-blue-50 to-transparent flex justify-end gap-3 sticky bottom-0 rounded-b-2xl">
|
||||
<button type="button" onclick="document.getElementById('createChecklistItemModal').classList.add('hidden')"
|
||||
class="px-6 py-2.5 border-2 border-slate-200 text-slate-700 rounded-xl font-bold hover:bg-slate-50 transition flex items-center gap-2">
|
||||
<i data-lucide="x-circle" class="w-4 h-4"></i>
|
||||
{% trans "Cancel" %}
|
||||
</button>
|
||||
<button type="button" onclick="saveChecklistItem()" id="saveChecklistItemBtn"
|
||||
class="bg-light0 text-white px-6 py-2.5 rounded-xl font-bold hover:bg-navy transition flex items-center gap-2">
|
||||
class="bg-gradient-to-r from-blue to-navy text-white px-6 py-2.5 rounded-xl font-bold hover:from-navy hover:to-blue transition flex items-center gap-2 shadow-lg shadow-blue-200">
|
||||
<i data-lucide="save" class="w-4 h-4"></i>
|
||||
<span id="saveBtnText">{% trans "Save Item" %}</span>
|
||||
<span class="animate-spin hidden" id="saveBtnSpinner">
|
||||
@ -305,32 +313,36 @@
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
|
||||
// Search functionality
|
||||
|
||||
// Search functionality with debounce
|
||||
let searchTimeout;
|
||||
document.getElementById('searchInput').addEventListener('keyup', function() {
|
||||
clearTimeout(searchTimeout);
|
||||
const searchValue = this.value.toLowerCase();
|
||||
const table = document.getElementById('itemsTable');
|
||||
const rows = table.getElementsByTagName('tr');
|
||||
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
const cells = row.getElementsByTagName('td');
|
||||
let found = false;
|
||||
|
||||
for (let j = 0; j < cells.length; j++) {
|
||||
const cellText = cells[j].textContent.toLowerCase();
|
||||
if (cellText.includes(searchValue)) {
|
||||
found = true;
|
||||
break;
|
||||
searchTimeout = setTimeout(() => {
|
||||
const table = document.getElementById('itemsTable');
|
||||
const rows = table.getElementsByTagName('tr');
|
||||
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
const cells = row.getElementsByTagName('td');
|
||||
let found = false;
|
||||
|
||||
for (let j = 0; j < cells.length; j++) {
|
||||
const cellText = cells[j].textContent.toLowerCase();
|
||||
if (cellText.includes(searchValue)) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
row.style.display = found ? '' : 'none';
|
||||
}
|
||||
|
||||
row.style.display = found ? '' : 'none';
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
});
|
||||
|
||||
// Save checklist item
|
||||
// Save checklist item (create or update)
|
||||
async function saveChecklistItem() {
|
||||
const form = document.getElementById('createChecklistItemForm');
|
||||
const saveBtn = document.getElementById('saveChecklistItemBtn');
|
||||
@ -338,16 +350,20 @@ async function saveChecklistItem() {
|
||||
const saveBtnSpinner = document.getElementById('saveBtnSpinner');
|
||||
const errorAlert = document.getElementById('formError');
|
||||
const errorMessage = document.getElementById('formErrorMessage');
|
||||
|
||||
|
||||
// Hide previous errors
|
||||
errorAlert.classList.add('hidden');
|
||||
|
||||
|
||||
// Validate form
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Check if we're editing an existing item
|
||||
const editItemId = form.dataset.editItemId;
|
||||
const isEditing = !!editItemId;
|
||||
|
||||
// Prepare form data
|
||||
const formData = new FormData(form);
|
||||
const data = {
|
||||
@ -362,42 +378,42 @@ async function saveChecklistItem() {
|
||||
is_active: formData.get('is_active') === 'on',
|
||||
order: parseInt(formData.get('order')) || 0
|
||||
};
|
||||
|
||||
|
||||
// Show loading state
|
||||
saveBtn.disabled = true;
|
||||
saveBtnText.textContent = '{% trans "Saving..." %}';
|
||||
saveBtnSpinner.classList.remove('hidden');
|
||||
|
||||
|
||||
try {
|
||||
// Get CSRF token
|
||||
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
||||
|
||||
// Send API request
|
||||
const response = await fetch('/api/accounts/onboarding/checklist/', {
|
||||
method: 'POST',
|
||||
// Use PUT for update, POST for create
|
||||
const url = isEditing
|
||||
? `/api/accounts/onboarding/checklist/${editItemId}/`
|
||||
: '/api/accounts/onboarding/checklist/';
|
||||
const method = isEditing ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
|
||||
if (response.ok) {
|
||||
// Close modal
|
||||
document.getElementById('createChecklistItemModal').classList.add('hidden');
|
||||
|
||||
// Show success message
|
||||
showAlert('{% trans "Checklist item created successfully!" %}', 'success');
|
||||
|
||||
// Reload page to show new item
|
||||
showAlert(isEditing
|
||||
? '{% trans "Checklist item updated successfully!" %}'
|
||||
: '{% trans "Checklist item created successfully!" %}', 'success');
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} else {
|
||||
// Show error
|
||||
errorMessage.textContent = responseData.error || responseData.detail || '{% trans "Failed to create checklist item" %}';
|
||||
errorMessage.textContent = responseData.error || responseData.detail || '{% trans "Failed to save checklist item" %}';
|
||||
errorAlert.classList.remove('hidden');
|
||||
}
|
||||
} catch (error) {
|
||||
@ -405,7 +421,6 @@ async function saveChecklistItem() {
|
||||
errorMessage.textContent = '{% trans "An error occurred. Please try again." %}';
|
||||
errorAlert.classList.remove('hidden');
|
||||
} finally {
|
||||
// Reset button state
|
||||
saveBtn.disabled = false;
|
||||
saveBtnText.textContent = '{% trans "Save Item" %}';
|
||||
saveBtnSpinner.classList.add('hidden');
|
||||
@ -414,22 +429,20 @@ async function saveChecklistItem() {
|
||||
|
||||
// Show alert message
|
||||
function showAlert(message, type = 'info') {
|
||||
// Create alert element
|
||||
const alert = document.createElement('div');
|
||||
const bgColor = type === 'success' ? 'bg-emerald-500' : 'bg-blue-500';
|
||||
alert.className = `fixed top-4 right-4 ${bgColor} text-white px-6 py-4 rounded-xl shadow-lg z-50 flex items-center gap-3`;
|
||||
const bgColor = type === 'success' ? 'bg-gradient-to-r from-emerald-500 to-emerald-600' : 'bg-gradient-to-r from-blue-500 to-blue-600';
|
||||
alert.className = `${bgColor} text-white px-6 py-4 rounded-xl shadow-xl z-50 flex items-center gap-3 fixed top-4 right-4`;
|
||||
alert.innerHTML = `
|
||||
${message}
|
||||
<i data-lucide="${type === 'success' ? 'check-circle' : 'info'}" class="w-5 h-5"></i>
|
||||
<span class="font-semibold">${message}</span>
|
||||
<button type="button" onclick="this.parentElement.remove()" class="text-white/80 hover:text-white">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Add to body
|
||||
|
||||
document.body.appendChild(alert);
|
||||
lucide.createIcons();
|
||||
|
||||
// Auto dismiss after 3 seconds
|
||||
|
||||
setTimeout(() => {
|
||||
alert.remove();
|
||||
}, 3000);
|
||||
@ -443,5 +456,83 @@ document.getElementById('createChecklistItemModal').addEventListener('click', fu
|
||||
document.getElementById('formError').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Edit checklist item - fetch item data and populate modal
|
||||
async function editChecklistItem(itemId) {
|
||||
try {
|
||||
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
||||
|
||||
const response = await fetch(`/api/accounts/onboarding/checklist/${itemId}/`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const item = await response.json();
|
||||
|
||||
// Populate form with existing data
|
||||
document.getElementById('code').value = item.code || '';
|
||||
document.getElementById('role').value = item.role || '';
|
||||
document.getElementById('content').value = item.content || '';
|
||||
document.getElementById('order').value = item.order || 0;
|
||||
document.getElementById('text_en').value = item.text_en || '';
|
||||
document.getElementById('text_ar').value = item.text_ar || '';
|
||||
document.getElementById('description_en').value = item.description_en || '';
|
||||
document.getElementById('description_ar').value = item.description_ar || '';
|
||||
document.getElementById('is_required').checked = item.is_required !== false;
|
||||
document.getElementById('is_active').checked = item.is_active !== false;
|
||||
|
||||
// Store item ID for update
|
||||
document.getElementById('createChecklistItemForm').dataset.editItemId = itemId;
|
||||
|
||||
// Update modal title
|
||||
document.querySelector('#createChecklistItemModal h3').innerHTML = '<i data-lucide="pencil" class="w-5 h-5 text-blue"></i> {% trans "Edit Checklist Item" %}';
|
||||
|
||||
// Show modal
|
||||
document.getElementById('createChecklistItemModal').classList.remove('hidden');
|
||||
lucide.createIcons();
|
||||
} else {
|
||||
showAlert('{% trans "Failed to load checklist item" %}', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
showAlert('{% trans "An error occurred loading the item" %}', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Delete checklist item
|
||||
async function deleteChecklistItem(itemId) {
|
||||
if (!confirm('{% trans "Are you sure you want to delete this checklist item?" %}')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
||||
|
||||
const response = await fetch(`/api/accounts/onboarding/checklist/${itemId}/`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok || response.status === 204) {
|
||||
showAlert('{% trans "Checklist item deleted successfully!" %}', 'success');
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} else {
|
||||
const responseData = await response.json().catch(() => ({}));
|
||||
showAlert(responseData.error || responseData.detail || '{% trans "Failed to delete checklist item" %}', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
showAlert('{% trans "An error occurred. Please try again." %}', 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@ -4,62 +4,82 @@
|
||||
{% block title %}{% trans "Manage Onboarding Content" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-6 md:p-8">
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<a href="{% url 'accounts:onboarding_dashboard' %}" class="inline-flex items-center text-navy hover:text-navy mb-2 font-medium">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i>
|
||||
{% trans "Back to Dashboard" %}
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold text-gray-800">
|
||||
{% trans "Onboarding Content" %}
|
||||
</h1>
|
||||
<p class="text-gray-500">
|
||||
{% trans "Manage the content shown during staff onboarding" %}
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-blue to-indigo-500 rounded-2xl shadow-lg shadow-blue-200">
|
||||
<i data-lucide="file-text" class="w-8 h-8 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{% url 'accounts:acknowledgement-dashboard' %}" class="inline-flex items-center text-blue hover:text-navy mb-2 font-medium">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i>
|
||||
{% trans "Back to Dashboard" %}
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold text-navy">
|
||||
{% trans "Onboarding Content" %}
|
||||
</h1>
|
||||
<p class="text-slate">
|
||||
{% trans "Manage the content shown during staff onboarding" %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{% url 'accounts:onboarding_content_create' %}" class="inline-flex items-center justify-center bg-gradient-to-r from-navy to-orange-500 text-white px-6 py-3 rounded-xl font-bold hover:from-navy hover:to-orange-600 transition shadow-lg shadow-blue-200">
|
||||
<a href="{% url 'accounts:onboarding_content_create' %}" class="inline-flex items-center justify-center bg-gradient-to-r from-blue to-navy text-white px-6 py-3 rounded-xl font-bold hover:from-navy hover:to-blue transition shadow-lg shadow-blue-200">
|
||||
<i data-lucide="plus" class="w-5 h-5 mr-2"></i>
|
||||
{% trans "Add Content" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Content List -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-blue-100 overflow-hidden">
|
||||
{% if content_items %}
|
||||
<div class="divide-y divide-gray-100">
|
||||
<div class="divide-y divide-blue-50">
|
||||
{% for item in content_items %}
|
||||
<div class="p-6 hover:bg-gray-50 transition">
|
||||
<div class="p-6 hover:bg-blue-50/50 transition">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex items-center justify-center w-12 h-12 bg-gradient-to-br from-blue to-indigo-500 rounded-xl flex-shrink-0">
|
||||
<i data-lucide="file-text" class="w-6 h-6 text-white"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h3 class="text-xl font-bold text-gray-800">{{ item.title }}</h3>
|
||||
<h3 class="text-xl font-bold text-navy">{{ item.title_en }}</h3>
|
||||
{% if item.is_active %}
|
||||
<span class="bg-green-100 text-green-600 text-xs font-bold px-2 py-1 rounded-full">Active</span>
|
||||
<span class="inline-flex items-center gap-1 bg-emerald-100 text-emerald-700 text-xs font-bold px-3 py-1 rounded-full">
|
||||
<i data-lucide="check" class="w-3 h-3"></i>
|
||||
{% trans "Active" %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="bg-gray-100 text-gray-500 text-xs font-bold px-2 py-1 rounded-full">Inactive</span>
|
||||
<span class="inline-flex items-center gap-1 bg-slate-100 text-slate-700 text-xs font-bold px-3 py-1 rounded-full">
|
||||
<i data-lucide="x" class="w-3 h-3"></i>
|
||||
{% trans "Inactive" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-gray-500 text-sm mb-3">
|
||||
{{ item.description|truncatewords:20 }}
|
||||
<p class="text-slate text-sm mb-3">
|
||||
{{ item.description_en|truncatewords:20 }}
|
||||
</p>
|
||||
<div class="flex items-center gap-4 text-sm text-gray-400">
|
||||
<span class="flex items-center gap-1">
|
||||
<i data-lucide="list" class="w-4 h-4"></i>
|
||||
{{ item.content_type }}
|
||||
<div class="flex items-center gap-4 text-sm">
|
||||
<span class="flex items-center gap-1 text-blue-700 font-medium">
|
||||
<i data-lucide="tag" class="w-4 h-4"></i>
|
||||
{{ item.code|default:"N/A" }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<i data-lucide="hash" class="w-4 h-4"></i>
|
||||
Step {{ item.step }}
|
||||
{% if item.role %}
|
||||
<span class="flex items-center gap-1 text-indigo-700 font-medium">
|
||||
<i data-lucide="user" class="w-4 h-4"></i>
|
||||
{{ item.get_role_display }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="flex items-center gap-1 text-slate-500">
|
||||
<i data-lucide="calendar" class="w-4 h-4"></i>
|
||||
{{ item.created_at|date:"M d, Y" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{% url 'accounts:onboarding_content_edit' item.pk %}" class="p-2 text-gray-400 hover:bg-blue-50 hover:text-blue-500 rounded-lg transition">
|
||||
<i data-lucide="edit" class="w-5 h-5"></i>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<a href="{% url 'accounts:onboarding_content_edit' item.pk %}" class="p-2.5 text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-xl transition" title="{% trans 'Edit' %}">
|
||||
<i data-lucide="pencil" class="w-5 h-5"></i>
|
||||
</a>
|
||||
<a href="{% url 'accounts:onboarding_content_delete' item.pk %}" class="p-2 text-gray-400 hover:bg-red-50 hover:text-red-500 rounded-lg transition">
|
||||
<a href="{% url 'accounts:onboarding_content_delete' item.pk %}" class="p-2.5 text-red-600 bg-red-50 hover:bg-red-100 rounded-xl transition" title="{% trans 'Delete' %}" onclick="return confirm('{% trans 'Are you sure you want to delete this content?' %}')">
|
||||
<i data-lucide="trash-2" class="w-5 h-5"></i>
|
||||
</a>
|
||||
</div>
|
||||
@ -69,12 +89,12 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-12 text-center">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 bg-gray-100 rounded-full mb-4">
|
||||
<i data-lucide="file-text" class="w-8 h-8 text-gray-400"></i>
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-full mb-4">
|
||||
<i data-lucide="file-text" class="w-10 h-10 text-blue-500"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-gray-700 mb-2">{% trans "No Content Yet" %}</h3>
|
||||
<p class="text-gray-500 mb-4">{% trans "Start by adding your first onboarding content item" %}</p>
|
||||
<a href="{% url 'accounts:onboarding_content_create' %}" class="inline-flex items-center bg-light0 text-white px-6 py-3 rounded-xl font-bold hover:bg-navy transition">
|
||||
<h3 class="text-xl font-bold text-navy mb-2">{% trans "No Content Yet" %}</h3>
|
||||
<p class="text-slate mb-6">{% trans "Start by adding your first onboarding content item" %}</p>
|
||||
<a href="{% url 'accounts:onboarding_content_create' %}" class="inline-flex items-center bg-gradient-to-r from-blue to-navy text-white px-8 py-3 rounded-xl font-bold hover:from-navy hover:to-blue transition shadow-lg shadow-blue-200">
|
||||
<i data-lucide="plus" class="w-5 h-5 mr-2"></i>
|
||||
{% trans "Add Content" %}
|
||||
</a>
|
||||
@ -88,4 +108,4 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@ -1,152 +1,267 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Onboarding Dashboard" %}{% endblock %}
|
||||
{% block title %}{% trans "Onboarding & Acknowledgements Dashboard" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-6 md:p-8">
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-800 mb-2">
|
||||
{% trans "Onboarding Dashboard" %}
|
||||
</h1>
|
||||
<p class="text-gray-500">
|
||||
{% trans "Manage staff onboarding progress and settings" %}
|
||||
</p>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-blue to-navy rounded-2xl shadow-lg shadow-blue-200">
|
||||
<i data-lucide="clipboard-check" class="w-8 h-8 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-navy mb-1">
|
||||
{% trans "Onboarding & Acknowledgements" %}
|
||||
</h1>
|
||||
<p class="text-slate">
|
||||
{% trans "Manage staff onboarding progress and acknowledgements" %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<div class="bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="bg-blue-100 p-2 rounded-xl">
|
||||
<i data-lucide="users" class="text-blue-500 w-5 h-5"></i>
|
||||
<div class="bg-white p-6 rounded-2xl shadow-sm border border-blue-100 hover:shadow-md transition">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-gradient-to-br from-blue to-blue-100 p-2.5 rounded-xl">
|
||||
<i data-lucide="users" class="text-blue w-5 h-5"></i>
|
||||
</div>
|
||||
<span class="text-slate text-sm font-semibold">{% trans "Total Invited" %}</span>
|
||||
</div>
|
||||
<span class="text-gray-500 text-sm font-medium">{% trans "Total Invited" %}</span>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-gray-800">{{ stats.total_invited }}</div>
|
||||
<div class="text-4xl font-bold text-navy">{{ stats.total_invited }}</div>
|
||||
<div class="mt-2 text-xs text-slate">{% trans "staff members invited" %}</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="bg-green-100 p-2 rounded-xl">
|
||||
<i data-lucide="check-circle" class="text-green-500 w-5 h-5"></i>
|
||||
<div class="bg-white p-6 rounded-2xl shadow-sm border border-emerald-100 hover:shadow-md transition">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-gradient-to-br from-emerald-500 to-emerald-600 p-2.5 rounded-xl">
|
||||
<i data-lucide="check-circle" class="text-white w-5 h-5"></i>
|
||||
</div>
|
||||
<span class="text-slate text-sm font-semibold">{% trans "Completed" %}</span>
|
||||
</div>
|
||||
<span class="text-gray-500 text-sm font-medium">{% trans "Completed" %}</span>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-gray-800">{{ stats.completed }}</div>
|
||||
<div class="text-4xl font-bold text-navy">{{ stats.completed }}</div>
|
||||
<div class="mt-2 text-xs text-slate">{% trans "completed onboarding" %}</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="bg-orange-100 p-2 rounded-xl">
|
||||
<i data-lucide="clock" class="text-orange-500 w-5 h-5"></i>
|
||||
<div class="bg-white p-6 rounded-2xl shadow-sm border border-orange-100 hover:shadow-md transition">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-gradient-to-br from-orange-400 to-orange-500 p-2.5 rounded-xl">
|
||||
<i data-lucide="clock" class="text-white w-5 h-5"></i>
|
||||
</div>
|
||||
<span class="text-slate text-sm font-semibold">{% trans "In Progress" %}</span>
|
||||
</div>
|
||||
<span class="text-gray-500 text-sm font-medium">{% trans "In Progress" %}</span>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-gray-800">{{ stats.in_progress }}</div>
|
||||
<div class="text-4xl font-bold text-navy">{{ stats.in_progress }}</div>
|
||||
<div class="mt-2 text-xs text-slate">{% trans "currently in progress" %}</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="bg-red-100 p-2 rounded-xl">
|
||||
<i data-lucide="x-circle" class="text-red-500 w-5 h-5"></i>
|
||||
<div class="bg-white p-6 rounded-2xl shadow-sm border border-red-100 hover:shadow-md transition">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-gradient-to-br from-red-500 to-red-600 p-2.5 rounded-xl">
|
||||
<i data-lucide="circle-x" class="text-white w-5 h-5"></i>
|
||||
</div>
|
||||
<span class="text-slate text-sm font-semibold">{% trans "Not Started" %}</span>
|
||||
</div>
|
||||
<span class="text-gray-500 text-sm font-medium">{% trans "Not Started" %}</span>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-gray-800">{{ stats.not_started }}</div>
|
||||
<div class="text-4xl font-bold text-navy">{{ stats.not_started }}</div>
|
||||
<div class="mt-2 text-xs text-slate">{% trans "haven't started" %}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<a href="{% url 'accounts:onboarding_bulk_invite' %}" class="group">
|
||||
<div class="bg-white p-6 rounded-2xl shadow-sm border-2 border-gray-100 hover:border-blue hover:shadow-md transition">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="bg-light p-3 rounded-xl group-hover:bg-blue-100 transition">
|
||||
<i data-lucide="mail-plus" class="text-navy w-6 h-6"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-gray-800 mb-1">{% trans "Send Bulk Invitations" %}</h3>
|
||||
<p class="text-sm text-gray-500">{% trans "Invite multiple staff members to onboarding" %}</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<a href="{% url 'accounts:bulk-invite-users' %}" class="group">
|
||||
<div class="bg-white p-6 rounded-2xl shadow-sm border-2 border-blue-100 hover:border-blue hover:shadow-lg hover:-translate-y-1 transition-all duration-200">
|
||||
<div class="flex items-center gap-4 mb-3">
|
||||
<div class="bg-gradient-to-br from-blue to-navy p-3 rounded-xl group-hover:shadow-lg transition">
|
||||
<i data-lucide="mail-plus" class="text-white w-6 h-6"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-navy">{% trans "Bulk Invite" %}</h3>
|
||||
</div>
|
||||
<p class="text-sm text-slate">{% trans "Invite multiple staff via CSV" %}</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'accounts:onboarding_checklist_list' %}" class="group">
|
||||
<div class="bg-white p-6 rounded-2xl shadow-sm border-2 border-gray-100 hover:border-blue-400 hover:shadow-md transition">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="bg-blue-100 p-3 rounded-xl group-hover:bg-blue-200 transition">
|
||||
<i data-lucide="clipboard-list" class="text-blue-500 w-6 h-6"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-gray-800 mb-1">{% trans "Manage Checklists" %}</h3>
|
||||
<p class="text-sm text-gray-500">{% trans "View and edit onboarding checklist items" %}</p>
|
||||
<a href="{% url 'accounts:acknowledgement-checklist-list' %}" class="group">
|
||||
<div class="bg-white p-6 rounded-2xl shadow-sm border-2 border-blue-100 hover:border-blue hover:shadow-lg hover:-translate-y-1 transition-all duration-200">
|
||||
<div class="flex items-center gap-4 mb-3">
|
||||
<div class="bg-gradient-to-br from-blue to-cyan-500 p-3 rounded-xl group-hover:shadow-lg transition">
|
||||
<i data-lucide="list-checks" class="text-white w-6 h-6"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-navy">{% trans "Checklist" %}</h3>
|
||||
</div>
|
||||
<p class="text-sm text-slate">{% trans "Manage checklist items" %}</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'accounts:onboarding_content_list' %}" class="group">
|
||||
<div class="bg-white p-6 rounded-2xl shadow-sm border-2 border-gray-100 hover:border-green-400 hover:shadow-md transition">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="bg-green-100 p-3 rounded-xl group-hover:bg-green-200 transition">
|
||||
<i data-lucide="file-text" class="text-green-500 w-6 h-6"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-gray-800 mb-1">{% trans "Manage Content" %}</h3>
|
||||
<p class="text-sm text-gray-500">{% trans "View and edit onboarding content" %}</p>
|
||||
<a href="{% url 'accounts:acknowledgement-content-list' %}" class="group">
|
||||
<div class="bg-white p-6 rounded-2xl shadow-sm border-2 border-blue-100 hover:border-blue hover:shadow-lg hover:-translate-y-1 transition-all duration-200">
|
||||
<div class="flex items-center gap-4 mb-3">
|
||||
<div class="bg-gradient-to-br from-blue to-indigo-500 p-3 rounded-xl group-hover:shadow-lg transition">
|
||||
<i data-lucide="file-text" class="text-white w-6 h-6"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-navy">{% trans "Content" %}</h3>
|
||||
</div>
|
||||
<p class="text-sm text-slate">{% trans "Manage onboarding content" %}</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'accounts:onboarding_provisional_list' %}" class="group">
|
||||
<div class="bg-white p-6 rounded-2xl shadow-sm border-2 border-gray-100 hover:border-purple-400 hover:shadow-md transition">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="bg-purple-100 p-3 rounded-xl group-hover:bg-purple-200 transition">
|
||||
<i data-lucide="user-plus" class="text-purple-500 w-6 h-6"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-gray-800 mb-1">{% trans "Provisional Accounts" %}</h3>
|
||||
<p class="text-sm text-gray-500">{% trans "View accounts pending activation" %}</p>
|
||||
<a href="{% url 'accounts:provisional-user-list' %}" class="group">
|
||||
<div class="bg-white p-6 rounded-2xl shadow-sm border-2 border-blue-100 hover:border-blue hover:shadow-lg hover:-translate-y-1 transition-all duration-200">
|
||||
<div class="flex items-center gap-4 mb-3">
|
||||
<div class="bg-gradient-to-br from-blue to-purple-500 p-3 rounded-xl group-hover:shadow-lg transition">
|
||||
<i data-lucide="user-plus" class="text-white w-6 h-6"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-navy">{% trans "Accounts" %}</h3>
|
||||
</div>
|
||||
<p class="text-sm text-slate">{% trans "View provisional accounts" %}</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
||||
<h2 class="text-xl font-bold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<i data-lucide="activity" class="w-5 h-5 text-navy"></i>
|
||||
{% trans "Recent Onboarding Activity" %}
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
{% for activity in recent_activity %}
|
||||
<div class="flex items-center gap-4 p-4 bg-gray-50 rounded-xl">
|
||||
<div class="flex items-center justify-center w-10 h-10 bg-white rounded-full shadow-sm">
|
||||
{% if activity.status == 'completed' %}
|
||||
<i data-lucide="check-circle" class="w-5 h-5 text-green-500"></i>
|
||||
{% elif activity.status == 'in_progress' %}
|
||||
<i data-lucide="loader" class="w-5 h-5 text-orange-500"></i>
|
||||
{% else %}
|
||||
<i data-lucide="clock" class="w-5 h-5 text-gray-400"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-gray-800">{{ activity.user }}</p>
|
||||
<p class="text-sm text-gray-500">{{ activity.action }}</p>
|
||||
</div>
|
||||
<span class="text-sm text-gray-400">{{ activity.timestamp|timesince }}</span>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Recent Activity -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-blue-100 overflow-hidden">
|
||||
<div class="px-6 py-5 border-b border-blue-100 bg-gradient-to-r from-blue-50 to-transparent">
|
||||
<h2 class="text-lg font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="activity" class="w-5 h-5 text-blue"></i>
|
||||
{% trans "Recent Activity" %}
|
||||
</h2>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="text-center py-8">
|
||||
<i data-lucide="inbox" class="w-12 h-12 text-gray-300 mx-auto mb-3"></i>
|
||||
<p class="text-gray-500">{% trans "No recent activity" %}</p>
|
||||
<div class="p-6">
|
||||
<div class="space-y-4">
|
||||
{% for activity in recent_activations %}
|
||||
<div class="flex items-center gap-4 p-4 bg-gradient-to-r from-blue-50/50 to-transparent rounded-xl border border-blue-50">
|
||||
<div class="flex items-center justify-center w-12 h-12 bg-white rounded-full shadow-sm border border-blue-100">
|
||||
<i data-lucide="check-circle" class="w-6 h-6 text-emerald-500"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-bold text-navy">{{ activity.get_full_name|default:activity.email }}</p>
|
||||
<p class="text-sm text-slate">{% trans "Completed onboarding" %} • {{ activity.acknowledgement_completed_at|timesince }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="text-center py-8">
|
||||
<i data-lucide="inbox" class="w-12 h-12 text-blue-200 mx-auto mb-3"></i>
|
||||
<p class="text-slate">{% trans "No recent activity" %}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Completion by Role -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-blue-100 overflow-hidden">
|
||||
<div class="px-6 py-5 border-b border-blue-100 bg-gradient-to-r from-blue-50 to-transparent">
|
||||
<h2 class="text-lg font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="circle-help" class="w-5 h-5 text-blue"></i>
|
||||
{% trans "Completion by Role" %}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="space-y-4">
|
||||
{% for role_data in completion_by_role %}
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-semibold text-navy">{{ role_data.role }}</span>
|
||||
<span class="text-sm font-bold text-blue">{{ role_data.rate }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-blue-100 h-2.5 rounded-full overflow-hidden">
|
||||
<div class="bg-gradient-to-r from-blue to-navy h-full rounded-full" style="width: {{ role_data.rate }}%"></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-1">
|
||||
<span class="text-xs text-emerald-600 font-medium">{{ role_data.completed }} completed</span>
|
||||
<span class="text-xs text-slate">{{ role_data.pending }} pending</span>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="text-center py-8">
|
||||
<i data-lucide="circle-help" class="w-12 h-12 text-blue-200 mx-auto mb-3"></i>
|
||||
<p class="text-slate">{% trans "No data available" %}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pending Users -->
|
||||
<div class="mt-6 bg-white rounded-2xl shadow-sm border border-blue-100 overflow-hidden">
|
||||
<div class="px-6 py-5 border-b border-blue-100 bg-gradient-to-r from-blue-50 to-transparent">
|
||||
<h2 class="text-lg font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="clock" class="w-5 h-5 text-blue"></i>
|
||||
{% trans "Pending Activations" %}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-blue-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "User" %}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Email" %}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Role" %}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Expires" %}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Status" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-blue-50">
|
||||
{% for user in pending_users %}
|
||||
<tr class="hover:bg-blue-50/50 transition">
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-10 h-10 bg-gradient-to-br from-blue to-navy rounded-full">
|
||||
<span class="text-white font-bold text-sm">{{ user.first_name|first|default:"U" }}{{ user.last_name|first|default:"" }}</span>
|
||||
</div>
|
||||
<span class="font-semibold text-navy">{{ user.get_full_name|default:user.email }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-slate">{{ user.email }}</td>
|
||||
<td class="px-6 py-4">
|
||||
{% if user.groups.first %}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-blue-100 text-blue-700">
|
||||
{{ user.groups.first.name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-sm {% if user.is_expiring_soon %}text-red-600 font-bold{% else %}text-slate{% endif %}">
|
||||
{% if user.days_remaining %}
|
||||
{{ user.days_remaining }} {% trans "days" %}
|
||||
{% else %}-{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{% if user.is_expiring_soon %}
|
||||
<span class="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-bold bg-red-100 text-red-700">
|
||||
<i data-lucide="alert-circle" class="w-3 h-3"></i>
|
||||
{% trans "Expiring Soon" %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-orange-100 text-orange-700">
|
||||
{% trans "Pending" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="px-6 py-12 text-center">
|
||||
<i data-lucide="check-circle" class="w-12 h-12 text-emerald-500 mx-auto mb-3"></i>
|
||||
<p class="text-slate">{% trans "All users have activated their accounts" %}</p>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -156,4 +271,4 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@ -4,81 +4,162 @@
|
||||
{% block title %}{% trans "Preview Onboarding" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-6 md:p-8">
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<a href="{% url 'accounts:onboarding_dashboard' %}" class="inline-flex items-center text-navy hover:text-navy mb-4 font-medium">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i>
|
||||
{% trans "Back to Dashboard" %}
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold text-gray-800 mb-2">
|
||||
{% trans "Preview Onboarding Flow" %}
|
||||
</h1>
|
||||
<p class="text-gray-500">
|
||||
{% trans "Preview the staff onboarding experience" %}
|
||||
</p>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-blue to-indigo-500 rounded-2xl shadow-lg shadow-blue-200">
|
||||
<i data-lucide="eye" class="w-8 h-8 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{% url 'accounts:acknowledgement-dashboard' %}" class="inline-flex items-center text-blue hover:text-navy mb-2 font-medium">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i>
|
||||
{% trans "Back to Dashboard" %}
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold text-navy">
|
||||
{% trans "Preview Onboarding Flow" %}
|
||||
</h1>
|
||||
<p class="text-slate">
|
||||
{% trans "Preview the staff onboarding experience by role" %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Steps -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div class="bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="flex items-center justify-center w-12 h-12 bg-gradient-to-br from-blue to-orange-500 rounded-xl">
|
||||
<span class="text-white font-bold">1</span>
|
||||
<!-- Role Selection -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-blue-100 p-6 md:p-8 mb-8">
|
||||
<h2 class="text-xl font-bold text-navy mb-6 flex items-center gap-2">
|
||||
<i data-lucide="user-cog" class="w-5 h-5 text-blue"></i>
|
||||
{% trans "Select Role to Preview" %}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{% for role in available_roles %}
|
||||
<a href="{% url 'accounts:preview-wizard-role' role.name %}"
|
||||
class="group p-6 border-2 {% if selected_role == role %}border-blue bg-blue-50{% else %}border-blue-100 hover:border-blue{% endif %} rounded-2xl transition-all hover:shadow-lg hover:-translate-y-1">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-blue to-navy rounded-xl flex items-center justify-center group-hover:shadow-lg transition">
|
||||
<i data-lucide="user" class="w-6 h-6 text-white"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-navy">{{ role.name }}</h3>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-gray-800">{% trans "Activation" %}</h3>
|
||||
<p class="text-sm text-gray-500">{% trans "Create account" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="#" class="block w-full text-center bg-gray-100 text-gray-700 py-2 rounded-xl font-medium hover:bg-gray-200 transition">
|
||||
{% trans "Preview" %}
|
||||
<p class="text-sm text-slate">{{ role.description|truncatechars:80|default:"Preview the onboarding flow for this role" }}</p>
|
||||
</a>
|
||||
{% empty %}
|
||||
<div class="col-span-full text-center py-12">
|
||||
<i data-lucide="user-x" class="w-12 h-12 text-blue-200 mx-auto mb-3"></i>
|
||||
<p class="text-slate">{% trans "No roles available" %}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if selected_role %}
|
||||
<!-- Preview Content for Selected Role -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- Content Sections -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-blue-100 overflow-hidden">
|
||||
<div class="px-6 py-5 border-b border-blue-100 bg-gradient-to-r from-blue-50 to-transparent">
|
||||
<h3 class="font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="file-text" class="w-5 h-5 text-blue"></i>
|
||||
{% trans "Content Sections" %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
{% if preview_content %}
|
||||
<div class="space-y-4">
|
||||
{% for content in preview_content %}
|
||||
<div class="p-4 border-2 border-blue-100 rounded-xl hover:border-blue hover:bg-blue-50/50 transition">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-blue to-indigo-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i data-lucide="file-check" class="w-5 h-5 text-white"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-bold text-navy mb-1">{{ content.title_en }}</h4>
|
||||
<p class="text-sm text-slate">{{ content.description_en|truncatewords:15 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-8">
|
||||
<i data-lucide="file-text" class="w-12 h-12 text-blue-200 mx-auto mb-3"></i>
|
||||
<p class="text-slate">{% trans "No content sections for this role" %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="flex items-center justify-center w-12 h-12 bg-gradient-to-br from-blue to-orange-500 rounded-xl">
|
||||
<span class="text-white font-bold">2</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-gray-800">{% trans "Checklist" %}</h3>
|
||||
<p class="text-sm text-gray-500">{% trans "Complete tasks" %}</p>
|
||||
</div>
|
||||
<!-- Checklist Items -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-blue-100 overflow-hidden">
|
||||
<div class="px-6 py-5 border-b border-blue-100 bg-gradient-to-r from-blue-50 to-transparent">
|
||||
<h3 class="font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="list-checks" class="w-5 h-5 text-blue"></i>
|
||||
{% trans "Checklist Items" %}
|
||||
</h3>
|
||||
</div>
|
||||
<a href="#" class="block w-full text-center bg-gray-100 text-gray-700 py-2 rounded-xl font-medium hover:bg-gray-200 transition">
|
||||
{% trans "Preview" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="flex items-center justify-center w-12 h-12 bg-gradient-to-br from-blue to-orange-500 rounded-xl">
|
||||
<span class="text-white font-bold">3</span>
|
||||
<div class="p-6">
|
||||
{% if preview_checklist %}
|
||||
<div class="space-y-3">
|
||||
{% for item in preview_checklist %}
|
||||
<div class="flex items-center gap-3 p-4 border-2 border-blue-100 rounded-xl hover:border-blue hover:bg-blue-50/50 transition">
|
||||
<div class="w-6 h-6 border-2 border-blue-300 rounded flex items-center justify-center flex-shrink-0">
|
||||
{% if item.is_required %}
|
||||
<i data-lucide="asterisk" class="w-3 h-3 text-blue-500"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-bold text-navy text-sm">{{ item.text_en }}</p>
|
||||
{% if item.is_required %}
|
||||
<span class="text-xs text-red-600 font-medium">{% trans "Required" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-gray-800">{% trans "Content" %}</h3>
|
||||
<p class="text-sm text-gray-500">{% trans "Review material" %}</p>
|
||||
{% else %}
|
||||
<div class="text-center py-8">
|
||||
<i data-lucide="list-checks" class="w-12 h-12 text-blue-200 mx-auto mb-3"></i>
|
||||
<p class="text-slate">{% trans "No checklist items for this role" %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<a href="#" class="block w-full text-center bg-gray-100 text-gray-700 py-2 rounded-xl font-medium hover:bg-gray-200 transition">
|
||||
{% trans "Preview" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Start Full Preview -->
|
||||
<div class="bg-gradient-to-br from-light to-blue-50 border-2 border-blue-200 rounded-2xl p-8 text-center">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-blue to-orange-500 rounded-full mb-4">
|
||||
<i data-lucide="play" class="w-8 h-8 text-white"></i>
|
||||
<div class="bg-gradient-to-br from-blue-50 to-indigo-50 border-2 border-blue-200 rounded-2xl p-8 text-center">
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-blue to-navy rounded-full mb-6 shadow-lg shadow-blue-200">
|
||||
<i data-lucide="play" class="w-10 h-10 text-white"></i>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-navy mb-2">{% trans "Start Full Preview" %}</h2>
|
||||
<p class="text-slate mb-6 max-w-2xl mx-auto">
|
||||
{% trans "Experience the complete onboarding flow from start to finish as a" %} <strong>{{ selected_role.name }}</strong> {% trans "user" %}
|
||||
</p>
|
||||
<div class="flex flex-wrap justify-center gap-4">
|
||||
<a href="{% url 'accounts:onboarding-welcome' %}" class="inline-flex items-center bg-gradient-to-r from-blue to-navy text-white px-8 py-4 rounded-xl font-bold hover:from-navy hover:to-blue transition shadow-lg shadow-blue-200">
|
||||
<i data-lucide="rocket" class="w-5 h-5 mr-2"></i>
|
||||
{% trans "Begin Preview" %}
|
||||
<i data-lucide="arrow-right" class="w-5 h-5 ml-2"></i>
|
||||
</a>
|
||||
<a href="{% url 'accounts:onboarding-step-checklist' %}" class="inline-flex items-center bg-white border-2 border-blue-200 text-navy px-8 py-4 rounded-xl font-bold hover:bg-blue-50 transition">
|
||||
<i data-lucide="list-checks" class="w-5 h-5 mr-2"></i>
|
||||
{% trans "View Checklist" %}
|
||||
</a>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-gray-800 mb-2">{% trans "Start Full Preview" %}</h2>
|
||||
<p class="text-gray-600 mb-6">{% trans "Experience the complete onboarding flow from start to finish" %}</p>
|
||||
<a href="#" class="inline-flex items-center bg-gradient-to-r from-navy to-orange-500 text-white px-8 py-4 rounded-xl font-bold hover:from-navy hover:to-orange-600 transition shadow-lg shadow-blue-200">
|
||||
{% trans "Begin Preview" %}
|
||||
<i data-lucide="arrow-right" class="w-5 h-5 ml-2"></i>
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- No Role Selected -->
|
||||
<div class="bg-gradient-to-br from-blue-50 to-indigo-50 border-2 border-blue-200 rounded-2xl p-12 text-center">
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-full mb-6">
|
||||
<i data-lucide="hand" class="w-10 h-10 text-blue-500"></i>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-navy mb-2">{% trans "Select a Role" %}</h2>
|
||||
<p class="text-slate max-w-xl mx-auto">
|
||||
{% trans "Choose a role from the options above to preview the onboarding experience for that specific role" %}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@ -86,4 +167,4 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@ -4,95 +4,122 @@
|
||||
{% block title %}{% trans "Onboarding Progress Detail" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-6 md:p-8">
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<a href="{% url 'accounts:onboarding_dashboard' %}" class="inline-flex items-center text-navy hover:text-navy mb-2 font-medium">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i>
|
||||
{% trans "Back to Dashboard" %}
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold text-gray-800">
|
||||
{{ account.get_full_name }}
|
||||
</h1>
|
||||
<p class="text-gray-500">
|
||||
{{ account.email }}
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-blue to-purple-500 rounded-2xl shadow-lg shadow-blue-200">
|
||||
<span class="text-white font-bold text-xl">{{ account.first_name|first|default:"U" }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{% url 'accounts:acknowledgement-dashboard' %}" class="inline-flex items-center text-blue hover:text-navy mb-2 font-medium">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i>
|
||||
{% trans "Back to Dashboard" %}
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold text-navy">
|
||||
{{ account.get_full_name|default:account.email }}
|
||||
</h1>
|
||||
<p class="text-slate">
|
||||
{{ account.email }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<span class="inline-flex items-center bg-light text-navy px-4 py-2 rounded-xl font-medium">
|
||||
<i data-lucide="mail" class="w-4 h-4 mr-2"></i>
|
||||
<span class="inline-flex items-center bg-blue-50 border border-blue-200 text-navy px-4 py-2 rounded-xl font-medium">
|
||||
<i data-lucide="mail" class="w-4 h-4 mr-2 text-blue"></i>
|
||||
{{ account.email }}
|
||||
</span>
|
||||
<span class="inline-flex items-center bg-gray-100 text-gray-600 px-4 py-2 rounded-xl font-medium">
|
||||
<i data-lucide="calendar" class="w-4 h-4 mr-2"></i>
|
||||
<span class="inline-flex items-center bg-white border border-blue-100 text-slate px-4 py-2 rounded-xl font-medium">
|
||||
<i data-lucide="calendar" class="w-4 h-4 mr-2 text-blue"></i>
|
||||
{{ account.invited_at|date:"M d, Y" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Overview -->
|
||||
<div class="bg-gradient-to-br from-light to-blue-50 rounded-2xl p-8 mb-8">
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-blue-100 p-8 mb-8">
|
||||
<h2 class="text-xl font-bold text-navy mb-6 flex items-center gap-2">
|
||||
<i data-lucide="circle-help" class="w-5 h-5 text-blue"></i>
|
||||
{% trans "Progress Overview" %}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div class="text-center">
|
||||
<div class="text-4xl font-bold text-gray-800 mb-2">
|
||||
<div class="text-center p-6 bg-gradient-to-br from-blue-50 to-indigo-50 rounded-2xl border border-blue-100">
|
||||
<div class="text-5xl font-bold text-navy mb-2">
|
||||
{{ progress.overall_percentage|default:0 }}%
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">{% trans "Overall Progress" %}</div>
|
||||
<div class="w-full bg-gray-200 h-2 rounded-full mt-3 overflow-hidden">
|
||||
<div class="bg-gradient-to-r from-navy to-orange-500 h-full" style="width: {{ progress.overall_percentage|default:0 }}%"></div>
|
||||
<div class="text-sm text-slate font-medium mb-3">{% trans "Overall Progress" %}</div>
|
||||
<div class="w-full bg-blue-100 h-3 rounded-full overflow-hidden">
|
||||
<div class="bg-gradient-to-r from-blue to-navy h-full rounded-full" style="width: {{ progress.overall_percentage|default:0 }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-4xl font-bold text-gray-800 mb-2">
|
||||
<div class="text-center p-6 bg-white rounded-2xl border-2 border-blue-100">
|
||||
<div class="flex items-center justify-center w-12 h-12 bg-gradient-to-br from-blue to-cyan-500 rounded-xl mx-auto mb-3">
|
||||
<i data-lucide="list-checks" class="w-6 h-6 text-white"></i>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-navy mb-2">
|
||||
{{ progress.checklist_completed|default:0 }}/{{ progress.checklist_total|default:0 }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">{% trans "Checklist Items" %}</div>
|
||||
<div class="text-sm text-slate">{% trans "Checklist Items" %}</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-4xl font-bold text-gray-800 mb-2">
|
||||
<div class="text-center p-6 bg-white rounded-2xl border-2 border-blue-100">
|
||||
<div class="flex items-center justify-center w-12 h-12 bg-gradient-to-br from-blue to-indigo-500 rounded-xl mx-auto mb-3">
|
||||
<i data-lucide="file-text" class="w-6 h-6 text-white"></i>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-navy mb-2">
|
||||
{{ progress.content_viewed|default:0 }}/{{ progress.content_total|default:0 }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">{% trans "Content Items" %}</div>
|
||||
<div class="text-sm text-slate">{% trans "Content Items" %}</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-4xl font-bold text-navy mb-2">
|
||||
<div class="text-center p-6 bg-white rounded-2xl border-2 border-blue-100">
|
||||
<div class="flex items-center justify-center w-12 h-12 bg-gradient-to-br from-blue to-purple-500 rounded-xl mx-auto mb-3">
|
||||
<i data-lucide="footprints" class="w-6 h-6 text-white"></i>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-navy mb-2">
|
||||
{{ progress.current_step|default:"-" }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">{% trans "Current Step" %}</div>
|
||||
<div class="text-sm text-slate">{% trans "Current Step" %}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Timeline -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-8 mb-8">
|
||||
<h2 class="text-xl font-bold text-gray-800 mb-6">{% trans "Onboarding Timeline" %}</h2>
|
||||
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-blue-100 p-8 mb-8">
|
||||
<h2 class="text-xl font-bold text-navy mb-6 flex items-center gap-2">
|
||||
<i data-lucide="timeline" class="w-5 h-5 text-blue"></i>
|
||||
{% trans "Onboarding Timeline" %}
|
||||
</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Step 1 -->
|
||||
<div class="flex gap-4">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-full {% if progress.activation_complete %}bg-gradient-to-br from-blue to-orange-500{% else %}bg-gray-200{% endif %}">
|
||||
<div class="flex items-center justify-center w-12 h-12 rounded-full {% if progress.activation_complete %}bg-gradient-to-br from-blue to-navy{% else %}bg-slate-200{% endif %} flex-shrink-0">
|
||||
{% if progress.activation_complete %}
|
||||
<i data-lucide="check" class="w-5 h-5 text-white"></i>
|
||||
<i data-lucide="check" class="w-6 h-6 text-white"></i>
|
||||
{% else %}
|
||||
<span class="text-gray-500 font-bold">1</span>
|
||||
<span class="text-slate-500 font-bold">1</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not progress.checklist_complete %}<div class="w-0.5 h-16 bg-gray-200"></div>{% endif %}
|
||||
{% if not progress.checklist_complete %}<div class="w-0.5 h-16 bg-slate-200"></div>{% endif %}
|
||||
</div>
|
||||
<div class="flex-1 pb-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="font-bold text-gray-800">{% trans "Account Activation" %}</h3>
|
||||
<h3 class="font-bold text-navy">{% trans "Account Activation" %}</h3>
|
||||
{% if progress.activation_complete %}
|
||||
<span class="text-sm text-green-600 font-medium">{% trans "Completed" %}</span>
|
||||
<span class="inline-flex items-center gap-1 text-sm font-bold text-emerald-600">
|
||||
<i data-lucide="check-circle" class="w-4 h-4"></i>
|
||||
{% trans "Completed" %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-sm text-gray-500">{% trans "Pending" %}</span>
|
||||
<span class="inline-flex items-center gap-1 text-sm font-medium text-slate-500">
|
||||
<i data-lucide="clock" class="w-4 h-4"></i>
|
||||
{% trans "Pending" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">{% trans "Create account and set password" %}</p>
|
||||
<p class="text-sm text-slate">{% trans "Create account and set password" %}</p>
|
||||
{% if progress.activation_completed_at %}
|
||||
<p class="text-xs text-gray-400 mt-2">
|
||||
<p class="text-xs text-slate mt-2">
|
||||
<i data-lucide="clock" class="w-3 h-3 inline mr-1"></i>
|
||||
{{ progress.activation_completed_at|date:"M d, Y H:i" }}
|
||||
</p>
|
||||
@ -103,29 +130,33 @@
|
||||
<!-- Step 2 -->
|
||||
<div class="flex gap-4">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-full {% if progress.checklist_complete %}bg-gradient-to-br from-blue to-orange-500{% else %}bg-gray-200{% endif %}">
|
||||
<div class="flex items-center justify-center w-12 h-12 rounded-full {% if progress.checklist_complete %}bg-gradient-to-br from-blue to-navy{% else %}bg-slate-200{% endif %} flex-shrink-0">
|
||||
{% if progress.checklist_complete %}
|
||||
<i data-lucide="check" class="w-5 h-5 text-white"></i>
|
||||
<i data-lucide="check" class="w-6 h-6 text-white"></i>
|
||||
{% else %}
|
||||
<span class="text-gray-500 font-bold">2</span>
|
||||
<span class="text-slate-500 font-bold">2</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not progress.content_complete %}<div class="w-0.5 h-16 bg-gray-200"></div>{% endif %}
|
||||
{% if not progress.content_complete %}<div class="w-0.5 h-16 bg-slate-200"></div>{% endif %}
|
||||
</div>
|
||||
<div class="flex-1 pb-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="font-bold text-gray-800">{% trans "Onboarding Checklist" %}</h3>
|
||||
<h3 class="font-bold text-navy">{% trans "Checklist Completion" %}</h3>
|
||||
{% if progress.checklist_complete %}
|
||||
<span class="text-sm text-green-600 font-medium">{% trans "Completed" %}</span>
|
||||
<span class="inline-flex items-center gap-1 text-sm font-bold text-emerald-600">
|
||||
<i data-lucide="check-circle" class="w-4 h-4"></i>
|
||||
{% trans "Completed" %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-sm text-gray-500">
|
||||
{{ progress.checklist_completed|default:0 }}/{{ progress.checklist_total|default:0 }}
|
||||
<span class="inline-flex items-center gap-1 text-sm font-medium text-slate-500">
|
||||
<i data-lucide="clock" class="w-4 h-4"></i>
|
||||
{% trans "In Progress" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">{% trans "Complete required onboarding tasks" %}</p>
|
||||
<p class="text-sm text-slate">{% trans "Complete all required checklist items" %}</p>
|
||||
{% if progress.checklist_completed_at %}
|
||||
<p class="text-xs text-gray-400 mt-2">
|
||||
<p class="text-xs text-slate mt-2">
|
||||
<i data-lucide="clock" class="w-3 h-3 inline mr-1"></i>
|
||||
{{ progress.checklist_completed_at|date:"M d, Y H:i" }}
|
||||
</p>
|
||||
@ -136,28 +167,32 @@
|
||||
<!-- Step 3 -->
|
||||
<div class="flex gap-4">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-full {% if progress.content_complete %}bg-gradient-to-br from-blue to-orange-500{% else %}bg-gray-200{% endif %}">
|
||||
<div class="flex items-center justify-center w-12 h-12 rounded-full {% if progress.content_complete %}bg-gradient-to-br from-blue to-navy{% else %}bg-slate-200{% endif %} flex-shrink-0">
|
||||
{% if progress.content_complete %}
|
||||
<i data-lucide="check" class="w-5 h-5 text-white"></i>
|
||||
<i data-lucide="check" class="w-6 h-6 text-white"></i>
|
||||
{% else %}
|
||||
<span class="text-gray-500 font-bold">3</span>
|
||||
<span class="text-slate-500 font-bold">3</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="font-bold text-gray-800">{% trans "Content Review" %}</h3>
|
||||
<h3 class="font-bold text-navy">{% trans "Content Review" %}</h3>
|
||||
{% if progress.content_complete %}
|
||||
<span class="text-sm text-green-600 font-medium">{% trans "Completed" %}</span>
|
||||
<span class="inline-flex items-center gap-1 text-sm font-bold text-emerald-600">
|
||||
<i data-lucide="check-circle" class="w-4 h-4"></i>
|
||||
{% trans "Completed" %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-sm text-gray-500">
|
||||
{{ progress.content_viewed|default:0 }}/{{ progress.content_total|default:0 }}
|
||||
<span class="inline-flex items-center gap-1 text-sm font-medium text-slate-500">
|
||||
<i data-lucide="clock" class="w-4 h-4"></i>
|
||||
{% trans "Pending" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">{% trans "Review onboarding material" %}</p>
|
||||
<p class="text-sm text-slate">{% trans "Review all onboarding content sections" %}</p>
|
||||
{% if progress.content_completed_at %}
|
||||
<p class="text-xs text-gray-400 mt-2">
|
||||
<p class="text-xs text-slate mt-2">
|
||||
<i data-lucide="clock" class="w-3 h-3 inline mr-1"></i>
|
||||
{{ progress.content_completed_at|date:"M d, Y H:i" }}
|
||||
</p>
|
||||
@ -167,18 +202,50 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-4">
|
||||
<a href="{% url 'accounts:onboarding_resend' account.pk %}" class="inline-flex items-center bg-blue-500 text-white px-6 py-3 rounded-xl font-bold hover:bg-blue-600 transition">
|
||||
<i data-lucide="mail" class="w-5 h-5 mr-2"></i>
|
||||
{% trans "Resend Invitation" %}
|
||||
</a>
|
||||
{% if not progress.activation_complete %}
|
||||
<a href="{% url 'accounts:onboarding_activate' account.pk %}" class="inline-flex items-center bg-gradient-to-r from-navy to-orange-500 text-white px-6 py-3 rounded-xl font-bold hover:from-navy hover:to-orange-600 transition">
|
||||
<i data-lucide="user-plus" class="w-5 h-5 mr-2"></i>
|
||||
{% trans "Activate Account" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<!-- Detailed Checklist -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-blue-100 overflow-hidden">
|
||||
<div class="px-6 py-5 border-b border-blue-100 bg-gradient-to-r from-blue-50 to-transparent">
|
||||
<h3 class="font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="list-checks" class="w-5 h-5 text-blue"></i>
|
||||
{% trans "Detailed Checklist Progress" %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
{% if progress.checklist_details %}
|
||||
<div class="space-y-3">
|
||||
{% for item in progress.checklist_details %}
|
||||
<div class="flex items-center gap-4 p-4 {% if item.completed %}bg-emerald-50 border border-emerald-100{% else %}bg-slate-50 border border-slate-100{% endif %} rounded-xl">
|
||||
<div class="flex items-center justify-center w-8 h-8 {% if item.completed %}bg-emerald-500{% else %}bg-slate-300{% endif %} rounded-lg flex-shrink-0">
|
||||
{% if item.completed %}
|
||||
<i data-lucide="check" class="w-5 h-5 text-white"></i>
|
||||
{% else %}
|
||||
<i data-lucide="minus" class="w-5 h-5 text-white"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-bold text-navy">{{ item.name }}</p>
|
||||
{% if item.completed_at %}
|
||||
<p class="text-xs text-slate">
|
||||
<i data-lucide="clock" class="w-3 h-3 inline mr-1"></i>
|
||||
{{ item.completed_at|date:"M d, Y H:i" }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if item.required %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-bold {% if item.completed %}bg-emerald-100 text-emerald-700{% else %}bg-red-100 text-red-700 %}">
|
||||
{% trans "Required" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-12">
|
||||
<i data-lucide="list-checks" class="w-12 h-12 text-blue-200 mx-auto mb-3"></i>
|
||||
<p class="text-slate">{% trans "No checklist items available" %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -187,4 +254,4 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@ -4,93 +4,120 @@
|
||||
{% block title %}{% trans "Provisional Accounts" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-6 md:p-8">
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<a href="{% url 'accounts:onboarding_dashboard' %}" class="inline-flex items-center text-navy hover:text-navy mb-2 font-medium">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i>
|
||||
{% trans "Back to Dashboard" %}
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold text-gray-800">
|
||||
{% trans "Provisional Accounts" %}
|
||||
</h1>
|
||||
<p class="text-gray-500">
|
||||
{% trans "View accounts pending activation" %}
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-blue to-purple-500 rounded-2xl shadow-lg shadow-blue-200">
|
||||
<i data-lucide="user-plus" class="w-8 h-8 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{% url 'accounts:acknowledgement-dashboard' %}" class="inline-flex items-center text-blue hover:text-navy mb-2 font-medium">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i>
|
||||
{% trans "Back to Dashboard" %}
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold text-navy">
|
||||
{% trans "Provisional Accounts" %}
|
||||
</h1>
|
||||
<p class="text-slate">
|
||||
{% trans "View accounts pending activation" %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Accounts List -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-blue-100 overflow-hidden">
|
||||
{% if provisional_accounts %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<thead class="bg-blue-50">
|
||||
<tr>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">
|
||||
{% trans "Name" %}
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">
|
||||
{% trans "User" %}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">
|
||||
{% trans "Email" %}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">
|
||||
{% trans "Department" %}
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">
|
||||
{% trans "Role" %}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">
|
||||
{% trans "Invited" %}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">
|
||||
{% trans "Expires" %}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">
|
||||
{% trans "Status" %}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">
|
||||
{% trans "Actions" %}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<tbody class="divide-y divide-blue-50">
|
||||
{% for account in provisional_accounts %}
|
||||
<tr class="hover:bg-gray-50 transition">
|
||||
<tr class="hover:bg-blue-50/50 transition">
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-10 h-10 bg-light rounded-full">
|
||||
<span class="text-navy font-bold text-sm">{{ account.first_name|first }}{{ account.last_name|first }}</span>
|
||||
<div class="flex items-center justify-center w-12 h-12 bg-gradient-to-br from-blue to-navy rounded-full">
|
||||
<span class="text-white font-bold text-sm">{{ account.first_name|first|default:"U" }}{{ account.last_name|first|default:"" }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-gray-800">{{ account.get_full_name }}</div>
|
||||
<div class="font-bold text-navy">{{ account.get_full_name|default:account.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-gray-600">{{ account.email }}</div>
|
||||
<div class="text-slate">{{ account.email }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-gray-600">{{ account.department|default:"-" }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-gray-500 text-sm">{{ account.invited_at|date:"M d, Y" }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{% if account.is_active %}
|
||||
<span class="bg-green-100 text-green-600 text-xs font-bold px-3 py-1 rounded-full">Active</span>
|
||||
{% elif account.activation_expires %}
|
||||
{% if account.activation_expires < now %}
|
||||
<span class="bg-red-100 text-red-600 text-xs font-bold px-3 py-1 rounded-full">Expired</span>
|
||||
{% else %}
|
||||
<span class="bg-orange-100 text-orange-600 text-xs font-bold px-3 py-1 rounded-full">Pending</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="bg-gray-100 text-gray-500 text-xs font-bold px-3 py-1 rounded-full">Inactive</span>
|
||||
{% if account.groups.first %}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-blue-100 text-blue-700">
|
||||
{{ account.groups.first.name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{% url 'accounts:onboarding_resend' account.pk %}" class="p-2 text-gray-400 hover:bg-blue-50 hover:text-blue-500 rounded-lg transition" title="Resend invitation">
|
||||
<i data-lucide="mail" class="w-5 h-5"></i>
|
||||
</a>
|
||||
<a href="{% url 'accounts:onboarding_delete' account.pk %}" class="p-2 text-gray-400 hover:bg-red-50 hover:text-red-500 rounded-lg transition" title="Delete account">
|
||||
<i data-lucide="trash-2" class="w-5 h-5"></i>
|
||||
</a>
|
||||
<span class="text-slate text-sm">{{ account.invited_at|date:"M d, Y" }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{% if account.invitation_expires_at %}
|
||||
{% if account.invitation_expires_at < now %}
|
||||
<span class="text-red-600 font-bold text-sm">{% trans "Expired" %}</span>
|
||||
{% else %}
|
||||
<span class="text-slate text-sm">{{ account.invitation_expires_at|date:"M d, Y" }}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-slate">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{% if account.is_active %}
|
||||
<span class="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-bold bg-emerald-100 text-emerald-700">
|
||||
<i data-lucide="check" class="w-3 h-3"></i>
|
||||
{% trans "Active" %}
|
||||
</span>
|
||||
elif account.invitation_expires_at and account.invitation_expires_at < now %}
|
||||
<span class="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-bold bg-red-100 text-red-700">
|
||||
<i data-lucide="alert-circle" class="w-3 h-3"></i>
|
||||
{% trans "Expired" %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-orange-100 text-orange-700">
|
||||
{% trans "Pending" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex gap-2">
|
||||
<button class="px-3 py-2 text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-lg transition font-medium text-sm" title="{% trans 'Resend Invite' %}">
|
||||
<i data-lucide="mail" class="w-4 h-4"></i>
|
||||
</button>
|
||||
<button class="px-3 py-2 text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition font-medium text-sm" title="{% trans 'Deactivate' %}">
|
||||
<i data-lucide="user-x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -100,15 +127,11 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-12 text-center">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 bg-gray-100 rounded-full mb-4">
|
||||
<i data-lucide="users" class="w-8 h-8 text-gray-400"></i>
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-emerald-100 to-green-100 rounded-full mb-4">
|
||||
<i data-lucide="check-circle" class="w-10 h-10 text-emerald-500"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-gray-700 mb-2">{% trans "No Provisional Accounts" %}</h3>
|
||||
<p class="text-gray-500 mb-4">{% trans "There are no accounts pending activation" %}</p>
|
||||
<a href="{% url 'accounts:onboarding_bulk_invite' %}" class="inline-flex items-center bg-light0 text-white px-6 py-3 rounded-xl font-bold hover:bg-navy transition">
|
||||
<i data-lucide="mail-plus" class="w-5 h-5 mr-2"></i>
|
||||
{% trans "Send Invitations" %}
|
||||
</a>
|
||||
<h3 class="text-xl font-bold text-navy mb-2">{% trans "All Accounts Activated" %}</h3>
|
||||
<p class="text-slate">{% trans "There are no pending provisional accounts" %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@ -119,4 +142,4 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
232
templates/accounts/simple_acknowledgements/admin_create.html
Normal file
232
templates/accounts/simple_acknowledgements/admin_create.html
Normal file
@ -0,0 +1,232 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Create Acknowledgement" %} - PX360{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="flex items-center justify-center w-12 h-12 bg-gradient-to-br from-emerald-500 to-emerald-600 rounded-2xl shadow-lg shadow-emerald-200">
|
||||
<i data-lucide="plus-circle" class="w-6 h-6 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-navy">{% trans "Create Acknowledgement" %}</h1>
|
||||
<p class="text-slate text-sm">{% trans "Create a new acknowledgement for employees to sign" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Main Form -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-slate-100 bg-gradient-to-r from-emerald-50 to-transparent">
|
||||
<h2 class="font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="file-text" class="w-5 h-5 text-emerald-600"></i>
|
||||
{% trans "Acknowledgement Details" %}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form method="post" enctype="multipart/form-data" class="p-6 space-y-6">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-navy mb-2">
|
||||
{% trans "Title" %} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="title" required
|
||||
class="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition"
|
||||
placeholder="{% trans 'e.g., Code of Conduct, Safety Policy, HIPAA Agreement' %}">
|
||||
<p class="text-xs text-slate mt-2">{% trans "Give this acknowledgement a clear, descriptive title" %}</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-navy mb-2">
|
||||
{% trans "Description" %}
|
||||
</label>
|
||||
<textarea name="description" rows="6"
|
||||
class="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition resize-none"
|
||||
placeholder="{% trans 'Enter the full text of the acknowledgement or a detailed description of what employees are agreeing to...' %}"></textarea>
|
||||
<p class="text-xs text-slate mt-2">{% trans "Employees will see this description when signing" %}</p>
|
||||
</div>
|
||||
|
||||
<!-- PDF Upload -->
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-navy mb-2">
|
||||
{% trans "Attach PDF Document" %}
|
||||
</label>
|
||||
<div class="border-2 border-dashed border-slate-300 rounded-xl p-8 text-center hover:border-emerald-500 hover:bg-emerald-50/30 transition cursor-pointer" onclick="document.getElementById('pdf-upload').click()">
|
||||
<input type="file" id="pdf-upload" name="pdf_document" accept=".pdf" class="hidden" onchange="handleFileSelect(this)">
|
||||
<div id="upload-placeholder">
|
||||
<i data-lucide="upload-cloud" class="w-12 h-12 text-slate-400 mx-auto mb-3"></i>
|
||||
<p class="text-slate font-medium">{% trans "Click to upload PDF" %}</p>
|
||||
<p class="text-sm text-slate mt-1">{% trans "or drag and drop here" %}</p>
|
||||
</div>
|
||||
<div id="upload-preview" class="hidden">
|
||||
<i data-lucide="file-text" class="w-12 h-12 text-emerald-500 mx-auto mb-3"></i>
|
||||
<p class="font-medium text-navy" id="filename"></p>
|
||||
<p class="text-sm text-emerald-600 mt-1">{% trans "Click to change file" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-slate mt-2">{% trans "Optional: Upload a PDF for employees to review before signing" %}</p>
|
||||
</div>
|
||||
|
||||
<!-- Settings -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="bg-emerald-50 border border-emerald-100 rounded-xl p-4">
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input type="checkbox" name="is_active" checked
|
||||
class="w-5 h-5 text-emerald-500 rounded focus:ring-emerald-500 mt-0.5">
|
||||
<div>
|
||||
<span class="font-semibold text-navy">{% trans "Active" %}</span>
|
||||
<p class="text-xs text-slate mt-1">{% trans "Show this acknowledgement in employee checklists immediately" %}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="bg-amber-50 border border-amber-100 rounded-xl p-4">
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input type="checkbox" name="is_required" checked
|
||||
class="w-5 h-5 text-amber-500 rounded focus:ring-amber-500 mt-0.5">
|
||||
<div>
|
||||
<span class="font-semibold text-navy">{% trans "Required" %}</span>
|
||||
<p class="text-xs text-slate mt-1">{% trans "All employees must sign this acknowledgement" %}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Display Order -->
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-navy mb-2">
|
||||
{% trans "Display Order" %}
|
||||
</label>
|
||||
<input type="number" name="order" value="0" min="0"
|
||||
class="w-32 px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition">
|
||||
<p class="text-xs text-slate mt-2">{% trans "Lower numbers appear first in the list (0 = top)" %}</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3 pt-4 border-t border-slate-100">
|
||||
<a href="{% url 'accounts:simple_acknowledgements:admin_list' %}"
|
||||
class="inline-flex items-center justify-center gap-2 px-6 py-3 bg-slate-100 text-slate-700 rounded-xl font-semibold hover:bg-slate-200 transition">
|
||||
<i data-lucide="x" class="w-5 h-5"></i>
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="flex-1 inline-flex items-center justify-center gap-2 px-6 py-3 bg-gradient-to-r from-emerald-500 to-emerald-600 text-white rounded-xl font-semibold hover:from-emerald-600 hover:to-emerald-700 transition shadow-lg shadow-emerald-200">
|
||||
<i data-lucide="check-circle" class="w-5 h-5"></i>
|
||||
{% trans "Create Acknowledgement" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help Sidebar -->
|
||||
<div class="lg:col-span-1">
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden sticky top-6">
|
||||
<div class="px-6 py-4 border-b border-slate-100 bg-gradient-to-r from-blue to-navy">
|
||||
<h3 class="font-bold text-white flex items-center gap-2">
|
||||
<i data-lucide="help-circle" class="w-5 h-5"></i>
|
||||
{% trans "Tips" %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<span class="font-bold text-blue text-sm">1</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-navy text-sm">{% trans "Clear Title" %}</p>
|
||||
<p class="text-xs text-slate mt-1">{% trans "Use a descriptive title so employees understand what they're signing" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<span class="font-bold text-blue text-sm">2</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-navy text-sm">{% trans "Detailed Description" %}</p>
|
||||
<p class="text-xs text-slate mt-1">{% trans "Include the full policy text or a clear summary" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<span class="font-bold text-blue text-sm">3</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-navy text-sm">{% trans "PDF Attachment" %}</p>
|
||||
<p class="text-xs text-slate mt-1">{% trans "Upload official policy documents for reference" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<span class="font-bold text-blue text-sm">4</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-navy text-sm">{% trans "Required vs Optional" %}</p>
|
||||
<p class="text-xs text-slate mt-1">{% trans "Mark critical policies as required" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t border-slate-100">
|
||||
<a href="{% url 'accounts:simple_acknowledgements:admin_list' %}"
|
||||
class="inline-flex items-center gap-2 text-sm text-blue hover:text-navy transition">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||
{% trans "Back to list" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function handleFileSelect(input) {
|
||||
const placeholder = document.getElementById('upload-placeholder');
|
||||
const preview = document.getElementById('upload-preview');
|
||||
const filename = document.getElementById('filename');
|
||||
|
||||
if (input.files && input.files[0]) {
|
||||
placeholder.classList.add('hidden');
|
||||
preview.classList.remove('hidden');
|
||||
filename.textContent = input.files[0].name;
|
||||
}
|
||||
}
|
||||
|
||||
// Drag and drop support
|
||||
const dropZone = document.querySelector('.border-dashed');
|
||||
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('border-emerald-500', 'bg-emerald-50');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('border-emerald-500', 'bg-emerald-50');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('border-emerald-500', 'bg-emerald-50');
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0 && files[0].type === 'application/pdf') {
|
||||
document.getElementById('pdf-upload').files = files;
|
||||
handleFileSelect(document.getElementById('pdf-upload'));
|
||||
} else {
|
||||
alert('{% trans "Please upload a PDF file" %}');
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user