This commit is contained in:
ismail 2026-03-09 16:10:24 +03:00
parent 60839790e8
commit 01fa26c59a
188 changed files with 28861 additions and 8711 deletions

View 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

View File

@ -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',
],
},
},

View 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

View 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>
```

View 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
View 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

View File

@ -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,
}

View File

@ -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_'

View 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))

View File

@ -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}"

View File

@ -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):

View 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'),
]

View 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

View File

@ -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'),
]

View File

@ -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)

View File

@ -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):
"""

View File

@ -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'
)

View File

@ -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:

View File

@ -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):

View File

@ -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('└─────────────────────────────────────┴─────────┴──────────┴──────────┴────────────┘')

View File

@ -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):

View File

@ -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',

View File

@ -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:

View File

@ -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:

View File

@ -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.

View File

@ -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": {{

View File

@ -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'

View File

@ -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
View 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
View 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()

View File

@ -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

View File

@ -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

View File

@ -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():
"""

View File

@ -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

View File

@ -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):
"""

View File

@ -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'),

View File

@ -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')

View File

@ -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:

View File

@ -1 +0,0 @@
# Integrations management commands

View File

@ -1 +0,0 @@
# Integrations management commands

View 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!'))

View 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)

View 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'))

View File

@ -1,3 +1,7 @@
"""
Integrations services package
"""
"""
from .his_adapter import HISAdapter
from .his_client import HISClient, HISClientFactory
__all__ = ['HISAdapter', 'HISClient', 'HISClientFactory']

View 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]

View File

@ -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

View File

@ -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) |

View File

@ -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']

View File

@ -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):

View File

@ -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"""

View File

@ -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

View File

@ -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']

View 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
View 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()

View File

@ -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})"

View File

@ -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)

View File

@ -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'),
]

View File

@ -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)

View File

@ -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'),

View File

@ -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

View 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

View File

@ -0,0 +1 @@
# Management package

View File

@ -0,0 +1 @@
# Commands package

View File

@ -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')

View 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

View 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

View File

@ -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)

View File

@ -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'),

View File

@ -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
View File

@ -0,0 +1 @@
# Reports App - Custom Report Builder

View File

363
apps/reports/models.py Normal file
View 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
View 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
View 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
View 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()}
})

View File

@ -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"),
]

View File

@ -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)

View File

@ -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):

View File

@ -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,

View File

@ -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):
"""

View File

@ -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',

View File

@ -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',
]

View File

@ -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')),

1281
data.json Normal file

File diff suppressed because it is too large Load Diff

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View File

@ -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&#10;email2@example.com&#10;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&#10;email2@example.com&#10;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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View 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